ADR-013: Wallet SDK Public API
| Status | Rejected (superseded by ADR-016) |
| Date | 2026-03-27 |
Context
The current ZkpWallet class mixes concerns: transaction methods, balance reads, history, account management, and stealth addresses all live on a single object. Method naming is inconsistent (toConcealed means different things on root vs wallet.public). Transactions return bare tx hashes with no metadata, and there's no way to separate proof generation from submission — critical for UIs that need a confirmation step between "proof ready" and "send". The single .account property conflates three distinct account types that have different capabilities and lifecycle requirements.
We need a public API that:
- Models three distinct account types (full, zk, redirect) as first-class concepts
- Gives UI developers a transaction lifecycle (prepare, confirm, send)
- Clearly maps transfer flows via explicit method naming (prefix = source, suffix = destination)
- Provides progress feedback for long-running proof generation
- Uses a consistent builder pattern for all chain writes
- Replaces stealth addresses with a unified account-series model
Design Principles
- Three account series: full, zk, redirect — all are sub-accounts derived from the master spending key
- Wallet = top-level container, controls access to account series
- No methods that throw based on context — if it exists, it works
- Prefix = source domain:
concealedTransfer= from concealed,publicTransfer= from public - Suffix = destination:
transfer= stay in domain,transferToPublic/transferToConcealed= cross domain transfer= moving tokens,send= writing to chain (final side-effect)TransactionBuilderfor all chain writes:.prepare()→.send()or one-shot.send()- No thenable — always explicit
.send() - Progress via
onProgress({ step, total, stage }) - Address format (zkp/0x) doesn't determine routing — SDK resolves internally
- "shield" is banned — use "concealed" in all public API. Internal contract calls may still use shield/unshield.
Proposal
Wallet
The Wallet is the top-level container. It controls access to three account series — sub-accounts derived from the master spending key. This replaces the old stealth address concept: accounts ARE the sub-accounts.
interface Wallet {
readonly full: AccountManager<FullAccount>;
readonly zk: AccountManager<ZkAccount>;
readonly redirect: RedirectAccountManager;
}
No wallet-level history or balance — these live per-account.
Account managers
Each manager tracks accounts created or recovered. at(index) returns a previously created or recovered account — it does not derive on the fly. Accounts persist in local storage across sessions: if you create() three accounts and restart the app, at(0), at(1), at(2) all work and list() returns all three. recover() scans the chain (checking both registration and balances to catch "burned" accounts that received funds but were never registered) and adds discovered accounts to local storage.
interface AccountManager<T> {
create(): T;
primary(): T; // returns at(0), creates if needed — convenience for single-account apps
at(index: number): T; // only for created/recovered indices
list(): T[]; // local storage — created + recovered accounts
recover(options?: RecoverOptions): Promise<T[]>; // scans chain: registration + balances
}
interface RecoverOptions {
maxIndex?: number;
consecutiveMisses?: number; // default 5
}
RedirectAccountManager is different — create() returns a builder, not an account. This makes it impossible to hold an unregistered redirect, since a redirect's entire purpose is to be registered with a zkAddress and auto-conceal enabled:
interface RedirectAccountManager {
create(options: { zkAddress: `zkp${string}` }): TransactionBuilder<RedirectCreationResult>;
at(index: number): RedirectAccount;
list(): RedirectAccount[]; // local storage
recover(options?: RecoverOptions): Promise<RedirectAccount[]>; // scans chain
}
Account types
ZK Account (concealed only)
No registration. Pure concealed operations — owner only. Has history and balance management.
interface ZkAccount {
readonly index: number;
readonly evmAddress: `0x${string}`;
getZkAddress(): `zkp${string}`;
readonly history: AccountHistory;
readonly balance: AccountBalance;
// Concealed transfers
concealedTransfer(token: string, amount: bigint, recipient: Recipient): TransactionBuilder; // concealed → concealed
concealedTransferToPublic(token: string, amount: bigint, recipient?: `0x${string}`): TransactionBuilder; // concealed → public
getConcealedBalance(token: string): Promise<bigint>;
}
Full Account (ZK + public + registration)
Extends ZK account with public methods and registration. Registration enables auto-conceal by default.
interface FullAccount {
readonly index: number;
readonly evmAddress: `0x${string}`;
getZkAddress(): `zkp${string}`;
register(options?: { autoConceal?: boolean }): TransactionBuilder; // autoConceal defaults true
isRegistered(): Promise<boolean>;
setAutoConceal(enabled: boolean): TransactionBuilder;
getAutoConceal(): Promise<boolean>;
readonly history: AccountHistory;
readonly balance: AccountBalance;
// Concealed transfers
concealedTransfer(token: string, amount: bigint, recipient: Recipient): TransactionBuilder; // concealed → concealed
concealedTransferToPublic(token: string, amount: bigint, recipient?: `0x${string}`): TransactionBuilder; // concealed → public
getConcealedBalance(token: string): Promise<bigint>;
// Public transfers
publicTransfer(token: string, amount: bigint, recipient: `0x${string}`): TransactionBuilder; // public → public
publicTransferToConcealed(token: string, amount: bigint, recipient?: Recipient): TransactionBuilder; // public → concealed
getPublicBalance(token: string): Promise<bigint>;
}
Redirect Account (public only, auto-conceals to zkAddress)
Created and registered in one atomic operation. No intermediate unregistered state exposed. No history, no concealed balance management. The redirect's purpose is to funnel public funds and auto-conceal them to a specified zkAddress owner. Senders interact with a redirect by sending to a normal EVM address — they don't need the SDK.
interface RedirectAccount {
readonly index: number;
readonly evmAddress: `0x${string}`;
readonly targetZkAddress: `zkp${string}`;
setAutoConceal(enabled: boolean): TransactionBuilder;
getAutoConceal(): Promise<boolean>;
getBalances(): Promise<Record<string, bigint>>;
// Public transfers
publicTransfer(token: string, amount: bigint, recipient: `0x${string}`): TransactionBuilder; // public → public
getPublicBalance(token: string): Promise<bigint>;
drain(token: string, recipient: `0x${string}`): TransactionBuilder; // sweep token to address
drainAll(recipient: `0x${string}`): TransactionBuilder; // sweep all tokens to address
}
Transaction naming convention
Prefix = source domain. Suffix = destination.
| Method | Flow | Needs Proof? |
|---|---|---|
account.concealedTransfer(token, amount, recipient) | concealed → concealed | Yes |
account.concealedTransferToPublic(token, amount, recipient?) | concealed → public | Yes |
account.publicTransfer(token, amount, recipient) | public → public | No |
account.publicTransferToConcealed(token, amount, recipient?) | public → concealed | No |
type Recipient = `zkp${string}` | `0x${string}`;
Address format (zkp/0x) does not determine routing — both formats are accepted everywhere. The SDK resolves via on-chain address registration. The zkp prefix encodes a compressed BPK. If an 0x address is passed to concealedTransfer and has no registered zkp counterpart on-chain, the SDK throws a descriptive error before proof generation — no silent fallback.
TransactionBuilder
All side-effects (chain writes) use the TransactionBuilder. send() is always the terminal method that executes the chain write. If a relayer is configured at the client level, all transactions are submitted via the relayer automatically — no per-call option needed. The builder is generic to allow extended result types.
interface TransactionBuilder<T extends TransactionResult = TransactionResult> {
prepare(options?: BuilderOptions): Promise<PreparedTransaction<T>>;
send(options?: BuilderOptions): Promise<T>;
}
interface PreparedTransaction<T extends TransactionResult = TransactionResult> {
send(options?: BuilderOptions): Promise<T>;
toJSON(): Record<string, unknown>; // for debugging, logging, and test assertions — not a submission path
}
interface BuilderOptions {
onProgress?: (info: ProgressInfo) => void;
}
No thenable/PromiseLike — you always explicitly call .send() or .prepare() then .send().
Transaction results
interface TransactionResult {
txHash: `0x${string}`;
blockNumber: bigint;
via: 'relayer' | 'direct';
receipt?: TransactionReceipt;
}
interface RedirectCreationResult extends TransactionResult {
readonly redirectAccount: RedirectAccount;
}
Registration
Registration uses the same builder pattern. register() is a single on-chain transaction that sets BPK and auto-conceal in one call:
// Full account registration (single tx: registers BPK + sets autoConceal)
await account.register().send(); // autoConceal: true by default
await account.register({ autoConceal: false }).send(); // opt out
// Redirect creation + registration (atomic)
const { redirectAccount } = await wallet.redirect.create({ zkAddress }).send();
Progress reporting
interface ProgressInfo {
step: number;
total: number;
stage: string;
}
Each flow declares its total step count. step is always 1-based and total always reflects the complete flow (prepare + submit combined), regardless of whether the caller uses one-shot .send() or two-step .prepare() then .send(). In the two-step case, prepare() reports steps 1 through N (prepare stages) and send() continues from N+1 through total (submit stages) — the counter does not reset.
| Flow | prepare stages | submit stages | total |
|---|---|---|---|
| concealed → concealed | reading-balance, decrypting, generating-proof, prepared | submitting, confirming | 6 |
| concealed → public | reading-balance, decrypting, generating-proof, prepared | submitting, confirming | 6 |
| public → concealed (relayer) | reading-nonce, signing-permit, prepared | submitting, confirming | 5 |
| public → concealed (direct) | prepared | submitting, confirming | 3 |
| public → public | prepared | submitting, confirming | 3 |
Per-account history & balance (ZK and Full only)
Redirect accounts have no history or balance namespace — only getBalances() and per-token getPublicBalance().
History
Returns { items, cursor } for pagination. undefined cursor means no more pages.
interface HistoryPage {
items: HistoryItem[];
cursor?: string;
}
interface AccountHistory {
get(options?: HistoryOptions & { currencies?: string[] }): Promise<HistoryPage>;
invalidateCache(currency?: string): void;
resolveByHash(txHash: `0x${string}`): Promise<HistoryItem>;
resolveFromLog(log: TransactionLog): Promise<HistoryItem>;
}
resolveByHash(txHash)— fetch from chain + decrypt amount + resolve parties into a fullHistoryItemresolveFromLog(log)— decrypt from an existing viem log without additional RPC calls
Pagination pattern:
let cursor: string | undefined;
const all: HistoryItem[] = [];
do {
const page = await account.history.get({ limit: 20, cursor });
all.push(...page.items);
cursor = page.cursor;
} while (cursor);
Balance (power-user cache management)
Per-token getConcealedBalance(token) / getPublicBalance(token) on the account is the simple path. AccountBalance exposes cross-currency reads and solver cache management for advanced integrations. seedCache() pre-populates the balance cache for optimistic UI updates — it is overwritten on the next getConcealedBalance() call.
interface AccountBalance {
getAllConcealed(): Promise<Record<string, bigint>>;
getAllPublic(): Promise<Record<string, bigint>>;
seedCache(currency: string, amount: bigint): void;
getCached(currency: string): bigint | null;
getCachedCiphertext(currency: string): ElGamalCiphertext | null;
decrypt(ct: ElGamalCiphertext): Promise<bigint>;
}
Token scoping
Token is passed as the first parameter on every transfer and balance method. Available tokens are determined by the config passed at wallet creation. No hardcoded .USD/.EUR/.PLN getters on the interface. Passing an unconfigured token throws a descriptive error.
const wallet = client.wallet({
tokens: { USD: '0x...', EUR: '0x...' }
});
const full = wallet.full.create();
await full.concealedTransfer('USD', 100n, 'zkp...').send(); // ok
await full.concealedTransfer('GBP', 100n, 'zkp...').send(); // throws: GBP not configured
Alternatives considered
Single wallet class with conditional methods. The previous approach: one ZkpWallet class with a single .account property and transaction methods that throw if called in the wrong context. Rejected because methods that exist but conditionally throw are a code smell — if it exists, it should work. The three account types have genuinely different capabilities.
Stealth addresses as a separate concept. The previous design had StealthManager and RedirectManager as wallet-level namespaces alongside a singular account. Rejected because stealth addresses and redirects are really just account types with different capabilities — the account manager model unifies creation, recovery, and lifecycle.
Thenable TransactionBuilder. Implementing PromiseLike so await usd.concealedTransfer(100n, 'zkp...') works without .send(). Rejected because implicit awaiting hides the chain write side-effect and can surprise developers who accidentally await when they intended to hold the builder.
Nested .public namespace on CurrencyAccount. token.public.transfer() instead of token.publicTransfer(). Rejected in favor of flat methods with explicit prefixes — simpler, no nesting, and the prefix/suffix convention is consistent across all account types.
Address-routed transactions. Infer destination domain from address format: zkp = concealed, 0x = public. Rejected because zkp and 0x addresses map to each other via on-chain registration — the address format is an identifier, not a routing decision.
Token-scoped sub-objects (CurrencyAccount). account.token('USD').concealedTransfer(100n, 'zkp...') instead of account.concealedTransfer('USD', 100n, 'zkp...'). Rejected because it introduces three additional CurrencyAccount interfaces without meaningful type safety gain — the token parameter is already validated against the wallet config at runtime. Flat methods with token as the first parameter are simpler, reduce interface count, and avoid intermediate stateful objects.
Viem-style functional API. All operations as standalone functions: concealedTransfer(client, { account, token, amount, to }). Offers tree-shaking and potentially familiar API, but the SDK's shared stateful services (prover, solver, storage) and the three-account-type model favor an OOP primary surface. The functional client parameter would carry the same state as the OOP Wallet with less honest encapsulation. It would have to keep remember private keys of all wallets that were created from it. Adopted partially: core logic is implemented as standalone functions internally, and can be exported under @cardinal/sdk/functions for advanced consumers and testing.
Wallet-level history and balance. Cross-account aggregation on the wallet root. Rejected in favor of per-account history and balance — simpler model, no ambiguity about which account's state you're querying.
Redirect with separate create/register steps. Allow creating an unregistered redirect locally, then registering later. Rejected because a redirect's entire purpose is to be registered with a zkAddress and auto-conceal enabled. Making create() return a builder forces atomic creation+registration and prevents developers from holding useless unregistered redirects.
Consequences
What becomes easier
- Three account types map cleanly to three different use cases — no conditional behavior
- No conditional throws — the type system communicates what's available per account type
- Builder pattern with explicit
.send()makes chain writes visible and intentional - Two-step prepare/send gives UIs the confirmation lifecycle they need
- Flat method naming (
concealedTransfer,publicTransfer) is greppable and unambiguous - Redirect creation is atomic — impossible to forget registration
- Recovery scans both registration and balances, catching "burned" accounts
zkpaddress format provides a clean public-facing identifier for concealed recipients
What becomes harder
- Three account types means more interfaces to learn upfront
concealedTransferToPublicis verbose — but explicit is better than ambiguous- The "concealed"/"public" terminology may confuse devs unfamiliar with ZKP systems
- No thenable means every chain write requires
.send()— slightly more typing for the simple case - Redirect accounts can't be pre-derived for display before registration (by design)
- Recovery is more expensive than simple registration checks (scans balances too)
Open items
- Error typing (separate PR)
- Swap API (deferred)
HistoryItem,HistoryOptions,TransactionLog,ElGamalCiphertext,TransactionReceipttype definitions- Recovery scan optimization strategies
- Event/subscription model — no way to subscribe to incoming transfers, balance changes, or auto-conceal events
- Batch/multicall support — composing multiple
TransactionBuilders into a single on-chain transaction - Relayer fallback strategy — current
tryRelayerWithFallbacksilently swallows all errors with no logging or consumer control - Standalone function exports (
@cardinal/sdk/functions) as secondary API for advanced consumers and testing - Prover prewarming (
client.prewarmProver()) — part of client/factory API, not wallet API, but must be surfaced for mobile UX TransactionResultconfirmation semantics — whether.send()resolves after broadcast or after mining (see below)
Appendix: Full Usage Example
const wallet = client.wallet(config);
// --- Full Account ---
const account = wallet.full.create();
await account.register().send();
// Concealed transfer
await account.concealedTransfer('USD', 100n, 'zkp...').send();
// With progress
await account.concealedTransfer('USD', 100n, 'zkp...').send({
onProgress({ step, total, stage }) {
console.log(`${step}/${total}: ${stage}`);
}
});
// Two-step (UI confirm)
const prepared = await account.concealedTransfer('USD', 100n, 'zkp...').prepare();
await prepared.send();
// Public transfer
await account.publicTransfer('USD', 200n, '0x...').send();
// Deposit to concealed
await account.publicTransferToConcealed('USD', 500n).send();
// Balances
const concealedBal = await account.getConcealedBalance('USD');
const publicBal = await account.getPublicBalance('USD');
// Power-user cache
account.balance.seedCache('USD', 100n);
// History with pagination
const { items, cursor } = await account.history.get({ limit: 20, currencies: ['USD'] });
const tx = await account.history.resolveByHash('0xabc...');
// --- ZK Account ---
const zk = wallet.zk.create();
await zk.concealedTransfer('USD', 50n, 'zkp...').send();
// --- Redirect Account ---
const { redirectAccount } = await wallet.redirect.create({ zkAddress: account.getZkAddress() }).send();
await redirectAccount.getPublicBalance('USD');
await redirectAccount.drain('USD', '0x...').send();
await redirectAccount.drainAll('0x...').send();
// --- Recovery ---
const recovered = await wallet.full.recover({ consecutiveMisses: 5 });