@circle-fin/unified-balance-kit
v1.0.2
Published
SDK for cross-chain USDC deposits, spending, and balance queries
Downloads
1,047
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
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.
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 @solana/spl-token
# or
yarn add @circle-fin/adapter-solana @solana/web3.js @solana/spl-tokenQuick 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: (feePayoutChain) => {
return feePayoutChain.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',
},
},
})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?) | 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) | Initiate withdrawal from Gateway |
| removeFund(context, params) | Complete withdrawal (mint on destination) |
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
