Unified Balance Kit
A strongly-typed SDK for cross-chain USDC deposits, spending, and balance queries
Move USDC across chains with simple method calls—no manual bridging, no liquidity pre-positioning, no on-chain delays for normal spend flows
Table of Contents
- Unified Balance Kit
Overview
The Stablecoin Kit ecosystem is Circle's open-source effort to streamline stablecoin development with SDKs that are easy to use correctly and hard to misuse. Kits are cross-framework (viem, ethers, @solana/web3) and integrate cleanly into any stack. They're opinionated with sensible defaults, but offer escape hatches for full control. A pluggable architecture makes implementation flexible, and all kits are interoperable, so they can be composed to suit a wide range of use cases.
The Unified Balance Kit provides a high-level abstraction for cross-chain USDC operations via Circle's Gateway protocol. Deposit, spend (mint), and query balances across multiple chains with simple method calls—no manual bridging steps, no liquidity pre-positioning, and no on-chain delays for normal spend flows.
Use spend for normal movement out of a Unified Balance. removeFund is a trustless recovery path for situations where the normal spend flow is unavailable, and it requires a 7-day withdrawal delay before funds can be removed.
Why Unified Balance Kit?
- 🌐 Unified balance model: Single abstraction for deposits, spends, and balance queries across chains
- ⚡ Instant cross-chain moves: Pull from multiple source chains and mint on destination in one flow
- 🔧 Bring your own adapters: Use viem, ethers, or @solana/web3.js adapters
- 🔒 Production-ready: Leverages Circle's Gateway v1 with attestations and deterministic flows
- 🚀 Developer experience: Complete TypeScript support, comprehensive validation, and structured errors
- 📦 Multi-source spends: Allocate amounts from multiple chains in a single spend operation
- 🛡️ Robust error handling: Structured KitError with recoverability and retry support
- 📡 Event monitoring: Track lifecycle events for deposits, spends, and balance operations
Architecture Flow
The Unified Balance Kit follows a three-layer architecture designed for flexibility and type safety:
┌─────────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Unified Balance │────│ Provider │────│ Adapter │
│ Kit (Orchestrator) │ │ (Gateway v1) │ │ (Blockchain) │
└─────────────────────┘ └──────────────────┘ └─────────────────┘
- Adapter: Handles blockchain-specific operations (wallets, transactions, gas) and enables you to use whatever framework you're comfortable with (viem, ethers, @solana/web3.js)
- Provider: Implements the Gateway protocol (currently Gateway v1)
- Unified Balance Kit: Orchestrates adapters and providers with validation and routing
This separation ensures that each component has a single responsibility while maintaining seamless integration across the entire cross-chain lifecycle.
Installation
npm install @circle-fin/unified-balance-kit
# or
yarn add @circle-fin/unified-balance-kit
Adapters
Choose the appropriate adapter for your target chains:
# For EVM chains (Ethereum, Base, Arbitrum, etc.)
npm install @circle-fin/adapter-viem-v2 viem
# or
yarn add @circle-fin/adapter-viem-v2 viem
# For EVM chains using Ethers.js
npm install @circle-fin/adapter-ethers-v6
# or
yarn add @circle-fin/adapter-ethers-v6
# For Solana
npm install @circle-fin/adapter-solana @solana/web3.js
# or
yarn add @circle-fin/adapter-solana @solana/web3.js
Quick Start
🚀 Easiest Setup: Deposit and Spend
Best for: Getting started quickly, simple deposits and cross-chain spends
import {
createUnifiedBalanceKitContext,
deposit,
spend,
} from '@circle-fin/unified-balance-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2'
// Create context with default Gateway v1 provider
const context = createUnifiedBalanceKitContext()
// Create adapter that works across chains
const adapter = createViemAdapterFromPrivateKey({
privateKey: process.env.PRIVATE_KEY as `0x${string}`,
})
// Deposit USDC on Ethereum
await deposit(context, {
from: { adapter, chain: 'Ethereum' },
amount: '100',
})
// Spend (mint) USDC on Base by pulling from Ethereum
const result = await spend(context, {
amount: '50',
from: {
adapter,
allocations: { amount: '50', chain: 'Ethereum' },
},
to: { adapter, chain: 'Base' },
})
console.log('Spend tx:', result.txHash, result.explorerUrl)
🎯 Send to Different Address
Best for: Sending funds to someone else's wallet, custodial services
Use recipientAddress when the recipient is different from your adapter's address:
const result = await spend(context, {
amount: '50',
from: {
adapter,
allocations: { amount: '50', chain: 'Ethereum' },
},
to: {
adapter,
chain: 'Base',
recipientAddress: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
},
})
📊 Query Balances
Best for: Displaying aggregated or per-chain USDC balances
import {
createUnifiedBalanceKitContext,
getBalances,
} from '@circle-fin/unified-balance-kit'
const context = createUnifiedBalanceKitContext()
const adapter = createViemAdapterFromPrivateKey({
privateKey: process.env.PRIVATE_KEY as `0x${string}`,
})
// Get confirmed balances across all supported chains
const balances = await getBalances(context, {
sources: { adapter },
})
console.log('Confirmed:', balances.totalConfirmedBalance)
// Include pending deposits in the result
const withPending = await getBalances(context, {
sources: { adapter },
includePending: true,
})
console.log('Confirmed:', withPending.totalConfirmedBalance)
console.log('Pending:', withPending.totalPendingBalance)
💰 Cost Estimation
Best for: Showing users fees upfront before spending
import {
createUnifiedBalanceKitContext,
estimateSpend,
} from '@circle-fin/unified-balance-kit'
const context = createUnifiedBalanceKitContext()
const estimate = await estimateSpend(context, {
amount: '100',
from: {
adapter,
allocations: { amount: '100', chain: 'Ethereum' },
},
to: { adapter, chain: 'Base' },
})
console.log('Estimated fees:', estimate.fees)
Configuration
Kit Context
The kit uses a context object that holds providers. Create it with createUnifiedBalanceKitContext:
// Default: Gateway v1 provider
const context = createUnifiedBalanceKitContext()
// Or with additional providers
const customContext = createUnifiedBalanceKitContext({
providers: [myCustomProvider],
})
Spend Parameters
// Single source allocation
const spendParams = {
amount: '100',
from: {
adapter,
allocations: { amount: '100', chain: 'Ethereum' },
},
to: { adapter, chain: 'Base' },
}
// Multi-source allocation (pull from multiple chains)
const multiSourceSpend = {
amount: '100',
from: {
adapter,
allocations: [
{ amount: '50', chain: 'Ethereum' },
{ amount: '50', chain: 'Base' },
],
},
to: { adapter, chain: 'Avalanche' },
}
Balance Parameters
// By adapter (wallet-controlled)
const balances = await getBalances(context, {
sources: { adapter },
})
// By address (read-only, no adapter needed)
const balancesByAddress = await getBalances(context, {
sources: { address: '0x...', chains: ['Ethereum', 'Base'] },
})
Custom Fees
The kit supports custom fees on spend operations. Fees are added on top of the transfer amount. Use the class API with setCustomFeePolicy for dynamic fee calculation, or pass config.customFee per spend:
import { UnifiedBalanceKit } from '@circle-fin/unified-balance-kit'
const kit = new UnifiedBalanceKit()
// Kit-level policy (class API)
kit.setCustomFeePolicy({
computeFee: (params) => {
const total = parseFloat(params.amount)
return (total * 0.01).toFixed(6) // 1%
},
resolveFeeRecipientAddress: (destinationChain) => {
return destinationChain.type === 'solana'
? 'SolanaAddressBase58...'
: '0xEvmAddress...'
},
})
// Or per-spend override (works with both APIs)
await spend(context, {
amount: '100',
from: { adapter, allocations: { amount: '100', chain: 'Ethereum' } },
to: { adapter, chain: 'Base' },
config: {
customFee: {
value: '1.0',
recipientAddress: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
},
},
})
Fee recipients across chain types
spend() can pull funds from multiple source chains (e.g. combining an EVM
and a Solana source), but every fee payout in a spend mints to the same
single destination chain — the one you pass as to. So destinationChain
above is always the spend's destination, never a source chain, and exactly
one recipient address (valid on that destination) is resolved per spend.
If you spend to different destination chain types across calls (some spends
to an EVM chain, others to Solana), configure setFeeRecipients once with an
address for each chain type instead of writing chain-branching logic
yourself. setFeeRecipients only affects which recipient address is used
— you still need setCustomFeePolicy's computeFee to determine the fee
amount. Calling setFeeRecipients without ever calling
setCustomFeePolicy throws at spend time (there is no computeFee to
determine an amount):
kit.setCustomFeePolicy({
computeFee: (params) => (parseFloat(params.amount) * 0.01).toFixed(6), // 1%
})
kit.setFeeRecipients({
evm: '0xEvmAddress...',
solana: 'SolanaAddressBase58...',
})
At spend time the kit maps the resolved destination chain to its chain type
('evm' or 'solana' today) and looks up the matching entry.
setFeeRecipients takes priority over resolveFeeRecipientAddress for any
chain type it has an entry for — the callback is never invoked for that
type. If the map is missing an entry for the resolved destination's chain
type and a resolveFeeRecipientAddress callback is also configured, the
kit falls back to that callback for the missing type (a warning is logged
so the fallback isn't a silent surprise). The spend throws before any fee
collection is attempted only if neither the map nor the callback can
resolve an address for the destination's chain type. Existing integrations
using only resolveFeeRecipientAddress or a single-string recipientAddress
continue to work unchanged.
Error Handling
The kit uses structured KitError instances with consistent properties:
- code: Numeric error code (e.g., 1001, 5001)
- name: Human-readable ID (e.g.,
INPUT_VALIDATION_FAILED,ONCHAIN_TRANSACTION_REVERTED) - type: Error category (
INPUT,BALANCE,ONCHAIN,RPC,NETWORK) - recoverability:
FATAL,RETRYABLE, orRESUMABLE - message: User-friendly explanation
- cause.trace: Additional context for debugging
import {
createUnifiedBalanceKitContext,
spend,
KitError,
isKitError,
getErrorCode,
} from '@circle-fin/unified-balance-kit'
const context = createUnifiedBalanceKitContext()
try {
await spend(context, params)
} catch (error) {
if (isKitError(error)) {
console.error(`Error ${error.code}: ${error.name}`, error.message)
if (error.recoverability === 'RESUMABLE' && error.cause?.trace) {
// Handle retry (see Retrying Failed Mints)
}
}
throw error
}
Retrying Failed Mints
When the on-chain mint step fails after the transfer was committed (funds locked), the kit throws a KitError with recoverability: 'RESUMABLE' and attestation/signature in cause.trace. Use config.retry to reattempt.
On Solana, the mint does not create recipient token accounts. Both errors below list the missing accounts in cause.trace.missingTokenAccounts. If the missing account belongs to the Circle fee recipient, contact Circle support instead.
- Before the transfer (non-forwarder spends and
estimateSpend):spendchecks the recipient token accounts before any transfer is submitted. A missing account throws aVALIDATION_FAILEDKitErrorthat isFATAL. Create the accounts, then callspendagain. - At mint time (for example on
config.retry): a missing account throws aVALIDATION_FAILEDKitErrorthat isRESUMABLE. Create the accounts before retrying withconfig.retry; retrying first fails with the same error.
try {
const result = await spend(context, params)
console.log('Success:', result.txHash)
} catch (error) {
if (
error instanceof KitError &&
error.recoverability === 'RESUMABLE' &&
error.cause?.trace
) {
const { attestation, signature, missingTokenAccounts } = error.cause
.trace as {
attestation: string
signature: string
missingTokenAccounts?: { tokenAccount: string; mint: string }[]
}
if (missingTokenAccounts?.length) {
// Solana only: create the listed token accounts first, then retry
// with the same attestation. Retrying now fails the same way.
throw error
}
// Retry with the attestation
const result = await spend(context, {
...params,
config: { retry: { attestation, signature } },
})
console.log('Retry success:', result.txHash)
} else {
throw error
}
}
API Reference
Core Methods
| Method | Description |
|---|---|
deposit(context, params) | Deposit a supported token into the caller's account on a chain |
depositFor(context, params) | Deposit a supported token into another account |
spend(context, params) | Spend a supported token on a destination chain from source(s) |
estimateSpend(context, params) | Get fee estimate before spending |
getBalances(context, params) | Query aggregated and per-chain balances |
getSupportedChains(context, token?, options?) | Get chains supported by configured providers |
addDelegate(context, params) | Add a delegate for the account |
removeDelegate(context, params) | Remove a delegate |
getDelegateStatus(context, params) | Check delegate status: 'none', 'pending', or 'ready' |
initiateRemoveFund(context, params) | Start recovery-only fund removal and the 7-day delay |
removeFund(context, params) | Complete recovery-only fund removal after the 7-day delay |
Use spend for normal movement out of a Unified Balance. Use removeFund
only as a trustless recovery path when the normal spend flow is unavailable.
Gateway supports USDC, cirBTC, and EURC where each token has a configured
address. Check getSupportedChains(context, token) before choosing a chain.
FAST cross-chain deposits use CCTP v2 and accept USDC only.
Delegate permissions are per token on EVM and Solana. A delegate authorized
for USDC cannot spend EURC until it is separately authorized for EURC.
For an EURC spend to Solana, the recipient and any custom fee recipient must
already have an EURC associated token account. The SDK does not create these
accounts. Create them before spending. If one is missing, spend fails before
anything is signed; create the account and call spend again.
Delegate status and finality
After calling addDelegate, the delegate may not be immediately usable for spend on
chains with slow finality (e.g. Ethereum, Base, Arbitrum). getDelegateStatus returns
a tri-state that reflects Gateway's finality view:
const status = await kit.getDelegateStatus({ from, delegateAddress })
if (status === 'ready') { await kit.spend(...) }
if (status === 'pending') { /* poll until 'ready' */ }
'none'— not a delegate on-chain'pending'— delegate set on-chain but Gateway hasn't finalized it yet; spend will fail'ready'— finalized at Gateway; spend will succeed
Functional vs Class API
Functional API (recommended):
import {
createUnifiedBalanceKitContext,
deposit,
spend,
getBalances,
} from '@circle-fin/unified-balance-kit'
const context = createUnifiedBalanceKitContext()
await deposit(context, { from: { adapter, chain: 'Ethereum' }, amount: '100' })
const result = await spend(context, {
amount: '50',
from: { adapter, allocations: { amount: '50', chain: 'Ethereum' } },
to: { adapter, chain: 'Base' },
})
const balances = await getBalances(context, { sources: { adapter } })
Class API:
import { UnifiedBalanceKit } from '@circle-fin/unified-balance-kit'
const kit = new UnifiedBalanceKit()
kit.on('gateway.deposit.succeeded', (payload) => {
console.log('Deposit succeeded:', payload.data)
})
kit.on('gateway.spend.succeeded', (payload) => {
console.log('Spend succeeded:', payload.data.txHash)
})
await kit.deposit({ from: { adapter, chain: 'Ethereum' }, amount: '100' })
const result = await kit.spend({
amount: '50',
from: { adapter, allocations: { amount: '50', chain: 'Ethereum' } },
to: { adapter, chain: 'Base' },
})
Development
Building
# From the root of the monorepo
nx build @circle-fin/unified-balance-kit
Testing
# From the root of the monorepo
nx test @circle-fin/unified-balance-kit
Local Development
# Install dependencies
yarn install
# Build all packages
yarn build
# Build the unified-balance-kit specifically
nx build @circle-fin/unified-balance-kit
# Run tests
nx test @circle-fin/unified-balance-kit
Community & Support
- 💬 Discord: Join our community
License
This project is licensed under the Apache 2.0 License. Contact support for details.