Skip to main content
Version: develop

Read & Decrypt Balances

Balances in the encrypted balance system are stored on-chain as ciphertexts -- the chain can verify transfers without seeing amounts, but you need your encryption key to read your own balance. This guide shows how to decrypt your balance and transaction history.

Decryption only requires a viewing key -- you don't need your spending key. This means you can safely share viewing access (e.g., with a portfolio tracker or auditor) without giving them the ability to spend. See Keys & Addresses for details on the key hierarchy.

Unlike the full client, you only need a read-only runtime here -- no proving or transfer capabilities, just decryption.

Setup

import { createDecryptClient, createReadRuntime } from '@cardinal-cryptography/sdk'
import { baseSepolia } from 'viem/chains'
import { http } from 'viem'

// A read-only runtime provides decryption services only (no ZK proving)
// -- lighter than a full runtime since it doesn't load the prover
const runtime = await createReadRuntime()

// A read-only account can decrypt but can't sign or generate proofs
const account = runtime.createReadAccountFromMnemonic('your mnemonic here')

const client = createDecryptClient({
chain: baseSepolia,
transport: http(),
zkpAccount: account,
})

Decrypt current balance

The SDK fetches the encrypted balance from the contract, then decrypts it locally using your encryption key.

const balance = await client.getDecryptedBalance({ token: 'zkUSD' })
console.log(`Decrypted balance: ${balance}`)

Read-after-write consistency

A read issued immediately after a write can return stale (pre-write) state. Two causes:

  • Multi-RPC / fallback transports. If you configure fallback([...]) (or otherwise hit more than one node), a read right after a write may land on a node that hasn't yet seen the block and return the pre-write balance. Re-read with a short backoff until the expected state appears, or pin a single RPC for read-after-write flows.
  • { waitForReceipt: false } sends. send* waits for inclusion by default and resolves a { transactionHash } receipt, so a single-RPC read after the default send is safe. But if you pass { waitForReceipt: false }, the send resolves before mining — await the pending send's waitForReceipt() (or otherwise confirm) before reading back, or the read can observe pre-transaction state. See Transaction Lifecycle.

Decrypt transaction history

Each event in your history is decrypted individually. The type field tells you what happened:

const logs = await client.getDecryptedBalanceLogs({
token: 'zkUSD',
fromBlock: 1000n,
})

for (const log of logs) {
switch (log.type) {
case 'deposit':
console.log(`Deposited ${log.amount} from ${log.sender} at block ${log.blockNumber}`)
break
case 'withdrawal':
console.log(`Withdrew ${log.amount} to ${log.recipient}`)
break
case 'transfer_in':
console.log(`Received ${log.amount} from ${log.senderZkpAddress} (transfer ${log.transferId})`)
break
case 'transfer_out':
console.log(`Sent ${log.amount} to ${log.recipientZkpAddress} (transfer ${log.transferId})`)
break
}
}

log.amount is decrypted for both directions: the EncryptedTransfer event carries an amount ciphertext for each party, so you read the sent amount on transfer_out and the received amount on transfer_in with your own key. (Transfers made before a token was upgraded to emit per-party ciphertexts still resolve transfer_in amounts from calldata, but report transfer_out amount as 0.)

Filter by event type

const deposits = await client.getDecryptedBalanceLogs({
token: 'zkUSD',
fromBlock: 1000n,
events: ['deposit', 'transfer_in'],
})

Public token history

The sections above read your encrypted balance and history. The public ERC-20 ledger — the plain token balance an EOA holds, read with getPublicTokenBalance — has no encrypted events: its transfer history is just the standard ERC-20 Transfer event, so there's no SDK action for it. Read it directly with viem — the SDK exports both the address resolver and the token ABI (ZKP_TOKEN_ABI includes the ERC-20 Transfer event), so you don't hand-write either:

import { getAbiItem } from 'viem'
import { ZKP_TOKEN_ABI, resolveTokenAddress } from '@cardinal-cryptography/core'

const tokenAddress = resolveTokenAddress('zkUSD', publicClient.chain.id) // symbol → ERC-20 address
const transfer = getAbiItem({ abi: ZKP_TOKEN_ABI, name: 'Transfer' }) // the token's ERC-20 Transfer event

// `from` and `to` are separate indexed topics — query each direction and merge.
const [sent, received] = await Promise.all([
publicClient.getLogs({ address: tokenAddress, event: transfer, args: { from: owner }, fromBlock, toBlock }),
publicClient.getLogs({ address: tokenAddress, event: transfer, args: { to: owner }, fromBlock, toBlock }),
])

Paginate the block range yourself with successive [fromBlock, toBlock] windows to stay under hosted-RPC caps, and loop your token list for a cross-token view.

Cleanup

runtime.destroy()

How decryption works

Encrypted balances use ElGamal encryption -- a scheme that lets the contract perform math on encrypted values (like adding a transfer amount to your balance) without decrypting them. To read your balance, the SDK fetches the ciphertext and decrypts it client-side using your encryption key. No private data leaves your machine.