This guide covers the essential concepts and patterns for building a swap interface that abstracts away blockchain complexity.
Open Source Example All examples in this guide are 100% free and open-source. Clone the repository to quickly get started.
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
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.