This guide covers the essential concepts and patterns for building a swap interface that abstracts away blockchain complexity.
Core Architecture Overview
A successful chain-abstracted swap application requires understanding these key components:
- Smart Account Prediction: Determine account addresses before deployment
- Quote Management: Real-time quote fetching with expiration handling
- Transaction Signing: EIP-712 typed data signing with Privy
- Status Monitoring: Real-time transaction status polling
- Balance Validation: Ensure sufficient funds before execution
Prerequisites
Before diving into building the cross-chain swap interface, you’ll need to have the following prerequisites in place:
Development Environment
- Node.js 20+: Our project uses modern TypeScript features. Install using nvm or download from nodejs.org.
- pnpm: We’ll use pnpm as our package manager for its speed and efficiency. Install it by running
npm install -g pnpm
or follow the official installation guide.
Technical Knowledge
- Next.js App Router: Understanding of Next.js 15’s App Router architecture is essential for this project.
- React Hooks: Familiarity with React’s useState, useEffect, useCallback, and custom hooks.
- TypeScript: Basic knowledge of TypeScript types and interfaces.
- Tailwind CSS & shadcn/ui: We’ll use these for styling and components.
Privy Setup
To integrate Privy into your codebase, follow Privy’s Quickstart documentation.
Once Privy is set up, ensure you have the following:
- Privy App ID
- PrivyProvider configured on your client
- User login workflow organized and tested on the client side
Setting Up the Project
We will use Next.js 15 with app router & TypeScript for this project, along with Tailwind CSS for styling and shadcn/ui for components. Let’s initialize the project:
pnpm dlx shadcn@latest init
For wallet connection and transaction signing, we will use Privy:
pnpm add @tanstack/react-query wagmi viem @privy-io/react-auth
Environment Setup
Make sure to copy .env.example
into .env
:
NEXT_PUBLIC_API_URL=https://be.onebalance.io
NEXT_PUBLIC_API_KEY=
NEXT_PUBLIC_PRIVY_APP_ID=
1. Smart Account Integration
Understanding OneBalance Smart Accounts
OneBalance uses Smart Contract Accounts (SCAs) that can be predicted before deployment. This enables:
- Receiving funds before the account exists on-chain
- Seamless transaction execution across chains
- Gas sponsorship and batched operations
Implementation Pattern
export const accountApi = {
predictAddress: async (sessionAddress: string, adminAddress: string) => {
const response = await apiClient.post('/account/predict-address', {
sessionAddress,
adminAddress,
});
return response.data?.predictedAddress;
},
};
Key Concept: Use the same wallet address for both sessionAddress
and adminAddress
for simplicity. The predicted address becomes your user’s primary account identifier.
2. Quote Management & Lifecycle
Quote Request Structure
OneBalance quotes follow a specific request format that defines the swap parameters:
interface QuoteRequest {
from: {
account: {
sessionAddress: string; // Privy wallet address
adminAddress: string; // Same as session for simplicity
accountAddress: string; // Predicted smart account
};
asset: { assetId: string }; // e.g., "ds:usdc"
amount: string; // Token amount in wei
};
to: {
asset: { assetId: string }; // e.g., "ds:eth"
account?: string; // Optional: for transfers to other addresses
};
}
Quote Lifecycle Management
The quote lifecycle involves several critical stages:
- Validation: Check user balance before requesting quotes
- Expiration Handling: Quotes expire in 30 seconds - implement countdown timers
- Auto-refresh: Automatically fetch new quotes when current ones expire
- Debouncing: Prevent excessive API calls during user input
Implementation Pattern (from useQuotes.ts
):
// Debounced quote fetching
const debouncedGetQuote = useCallback(
debounce(async (request) => {
await getQuote(request);
}, 1000),
[getQuote]
);
// Balance validation before quote request
const hasSufficientBalance = (amount: string) => {
if (!sourceBalance || !selectedAsset || !amount) return false;
const parsedAmount = parseTokenAmount(amount, selectedAsset.decimals);
return BigInt(sourceBalance.balance) >= BigInt(parsedAmount);
};
3. Transaction Signing with Privy
EIP-712 Typed Data Signing
OneBalance transactions require signing structured data (EIP-712) for each chain operation. The signing process handles multiple operations sequentially.
Key Implementation (from privySigningUtils.ts
):
import { Address, createWalletClient, custom, Hash } from 'viem';
import { ConnectedWallet } from '@privy-io/react-auth';
import { ChainOperation, Quote } from '@/lib/types/quote';
export const signTypedDataWithPrivy =
(embeddedWallet: ConnectedWallet) =>
async (typedData: any): Promise<Hash> => {
const provider = await embeddedWallet.getEthereumProvider();
const walletClient = createWalletClient({
transport: custom(provider),
account: embeddedWallet.address as Address,
});
return walletClient.signTypedData(typedData);
};
export const signOperation =
(embeddedWallet: ConnectedWallet) =>
(operation: ChainOperation): (() => Promise<ChainOperation>) =>
async () => {
const signature = await signTypedDataWithPrivy(embeddedWallet)(operation.typedDataToSign);
return {
...operation,
userOp: { ...operation.userOp, signature },
};
};
export const signQuote = async (quote: Quote, embeddedWallet: ConnectedWallet) => {
const signWithEmbeddedWallet = signOperation(embeddedWallet);
const signedQuote = {
...quote,
};
signedQuote.originChainsOperations = await sequentialPromises(
quote.originChainsOperations.map(signWithEmbeddedWallet)
);
if (quote.destinationChainOperation) {
signedQuote.destinationChainOperation = await signWithEmbeddedWallet(
quote.destinationChainOperation
)();
}
return signedQuote;
};
// Helper to run an array of lazy promises in sequence
export const sequentialPromises = (promises: (() => Promise<any>)[]): Promise<any[]> => {
return promises.reduce<Promise<any[]>>(
(acc, curr) => acc.then(results => curr().then(result => [...results, result])),
Promise.resolve([])
);
};
Critical Points:
- Each quote contains multiple
ChainOperation
objects that need individual signatures
- Operations must be signed sequentially to avoid nonce conflicts
- The
typedDataToSign
field contains the EIP-712 structure for each operation
4. Transaction Execution Flow
Complete Transaction Workflow
- Quote Validation: Check expiration before execution
- Signing: Sign all required operations using Privy
- Execution: Submit signed quote to OneBalance
- Monitoring: Poll transaction status until completion
Implementation Pattern:
const executeQuote = async () => {
// 1. Validate quote expiration
const expirationTime = parseInt(quote.expirationTimestamp) * 1000;
if (Date.now() > expirationTime) {
throw new Error('Quote has expired');
}
// 2. Sign quote with Privy
const signedQuote = await signQuote(quote, embeddedWallet);
// 3. Execute signed quote
await quotesApi.executeQuote(signedQuote);
// 4. Start status polling
startStatusPolling(quote.id);
};
5. Real-Time Status Monitoring
Status Polling Implementation
After execution, implement real-time polling to track transaction progress:
// Polling pattern from useQuotes.ts
const pollTransactionStatus = (quoteId: string) => {
const interval = setInterval(async () => {
try {
const status = await quotesApi.getQuoteStatus(quoteId);
if (status.status === 'COMPLETED' || status.status === 'FAILED') {
clearInterval(interval);
handleTransactionComplete(status);
}
} catch (error) {
console.error('Polling error:', error);
clearInterval(interval);
}
}, 1000); // Poll every second
};
Status Types:
PENDING
: Transaction submitted to blockchain
IN_PROGRESS
: Being processed across chains
COMPLETED
: Successfully completed
FAILED
: Transaction failed
REFUNDED
: Funds returned to user
6. User Experience Enhancements
Quote Countdown Timer
Show users when quotes will expire:
// From QuoteCountdown component
import { useState, useEffect, useCallback } from 'react';
export function useCountdown(timestamp: number, onExpire?: () => void) {
const [timeLeft, setTimeLeft] = useState<number>(0);
useEffect(() => {
const calculateTimeLeft = () => {
const difference = timestamp * 1000 - Date.now();
return Math.max(0, Math.floor(difference / 1000));
};
setTimeLeft(calculateTimeLeft());
const timer = setInterval(() => {
const newTimeLeft = calculateTimeLeft();
setTimeLeft(newTimeLeft);
if (newTimeLeft < 1) {
clearInterval(timer);
onExpire?.();
}
}, 1000);
return () => clearInterval(timer);
}, [timestamp, onExpire]);
const formatTime = useCallback((seconds: number) => {
if (seconds === 0) {
return 'Expired';
}
const secs = seconds % 60;
return `${secs.toString()}`;
}, []);
return {
timeLeft,
formattedTime: formatTime(timeLeft),
isExpired: timeLeft === 0,
};
}
7. Error Handling & Recovery
Graceful Error Management
Implement error handling for all failure scenarios:
// Quote error handling
if (quote?.error) {
return (
<Alert variant="destructive">
<AlertTitle>Quote Error</AlertTitle>
<AlertDescription>{quote.message}</AlertDescription>
</Alert>
);
}
// Transaction failure recovery
const handleTransactionFailure = (error) => {
// Clear state
resetQuote();
// Show user-friendly error
setError(error.message || 'Transaction failed');
// Refresh balances to ensure accuracy
fetchBalances();
};
8. API Integration Patterns
Modular API Structure
Organize API calls into logical modules:
// Organized API structure
export const oneBalanceApi = {
// Account management
predictAddress: (sessionAddress, adminAddress) => {...},
// Quote operations
getQuote: (quoteRequest) => {...},
executeQuote: (signedQuote) => {...},
getQuoteStatus: (quoteId) => {...},
// Asset & balance data
getSupportedAssets: () => {...},
getAggregatedBalance: (address) => {...},
};
Proxy Pattern for CORS
Use Next.js API routes to handle CORS and secure API keys:
app/api/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { API_BASE_URL, API_KEY } from '@/lib/constants';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const pathString = path.join('/');
const searchParams = request.nextUrl.searchParams;
try {
// Build the API URL with any query parameters
const apiUrl = new URL(`/api/${pathString}`, API_BASE_URL);
searchParams.forEach((value, key) => {
apiUrl.searchParams.append(key, value);
});
const response = await fetch(apiUrl.toString(), {
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
});
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ message: 'Failed to fetch data', error }, { status: 400 });
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const pathString = path.join('/');
try {
const body = await request.json();
const response = await fetch(`${API_BASE_URL}/api/${pathString}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ message: 'Failed to fetch data', error }, { status: 400 });
}
}
Key Integration Concepts
Chain Abstraction Benefits
- Unified Token Balances: Users see aggregated balances across all chains
- Automatic Routing: OneBalance finds optimal paths for swaps
- Gas Sponsorship: No need for users to hold native tokens for gas
- Network Abstraction: Users never need to think about which chain they’re using
Production Considerations
- Error Boundaries: Implement React error boundaries for graceful failures
- Rate Limiting: Implement client-side rate limiting for API calls
- Cache Management: Cache asset data and balance information appropriately
- Security: Never expose API keys in client-side code
The complete implementation demonstrates these patterns in production. Check the GitHub repository for detailed examples and best practices.