@circle-fin/unified-balance-kit
v1.4.0
Published
SDK for cross-chain USDC deposits, spending, and balance queries
Readme
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-kitAdapters
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.jsQuick 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:
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 } = error.cause.trace as {
attestation: string
signature: string
}
// 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 USDC into the caller's account on a chain |
| depositFor(context, params) | Deposit USDC into another account |
| spend(context, params) | Spend (mint) USDC on a destination chain by pulling 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.
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-kitTesting
# From the root of the monorepo
nx test @circle-fin/unified-balance-kitLocal 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-kitCommunity & Support
- 💬 Discord: Join our community
License
This project is licensed under the Apache 2.0 License. Contact support for details.
Ready to build cross-chain USDC apps?
Built with ❤️ by Circle
