Simplified UX: By combining Privy’s seamless wallet experience with OneBalance’s chain
abstraction, users don’t need to worry about networks, gas, or bridging.
Type Definitions
First, let’s create type definitions for our quotes and operations. Create a new file atsrc/lib/types/quote.ts:
quote.ts
Copy
Ask AI
// src/lib/types/quote.ts
export interface Account {
sessionAddress: string;
adminAddress: string;
accountAddress: string;
}
export interface TokenInfo {
aggregatedAssetId: string;
amount: string;
assetType: string | string[];
fiatValue: never;
}
export interface ChainOperation {
userOp: {
sender: string;
nonce: string;
callData: string;
callGasLimit: string;
verificationGasLimit: string;
preVerificationGas: string;
maxFeePerGas: string;
maxPriorityFeePerGas: string;
paymaster: string;
paymasterVerificationGasLimit: string;
paymasterPostOpGasLimit: string;
paymasterData: string;
signature: string;
};
typedDataToSign: {
domain: unknown;
types: unknown;
primaryType: string;
message: unknown;
};
assetType: string;
amount: string;
}
export interface Quote {
id: string;
account: Account;
originToken: TokenInfo;
destinationToken: TokenInfo;
expirationTimestamp: string;
tamperProofSignature: string;
originChainsOperations: ChainOperation[];
destinationChainOperation?: ChainOperation;
}
export interface QuoteStatus {
quoteId: string;
status: {
status: 'PENDING' | 'COMPLETED' | 'FAILED' | 'IN_PROGRESS' | 'REFUNDED';
};
user: string;
recipientAccountId: string;
originChainOperations: {
hash: string;
chainId: number;
explorerUrl: string;
}[];
destinationChainOperations: {
hash: string;
chainId: number;
explorerUrl: string;
}[];
}
Setting Up the Signing Utilities
Now, let’s set up some helper functions for signing operations with the Privy wallet. Create a new file atsrc/lib/privySigningUtils.ts:
privySigningUtils.ts
Copy
Ask AI
// src/lib/privySigningUtils.ts
import { Address, createWalletClient, custom, Hash } from 'viem';
import { ConnectedWallet } from '@privy-io/react-auth';
import { ChainOperation, Quote } from '@/lib/types/quote';
// Create a function to sign typed data with Privy wallet
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);
};
// Create a function to sign a chain operation
export const signOperation =
(embeddedWallet: ConnectedWallet) =>
(operation: ChainOperation): (() => Promise<ChainOperation>) =>
async () => {
const signature = await signTypedDataWithPrivy(embeddedWallet)(operation.typedDataToSign);
return {
...operation,
userOp: { ...operation.userOp, signature },
};
};
// Create a function to sign the entire quote
export const signQuote = async (quote: Quote, embeddedWallet: ConnectedWallet) => {
const signWithEmbeddedWallet = signOperation(embeddedWallet);
const signedQuote = {
...quote,
};
// Sign all operations in sequence
if (quote.originChainsOperations) {
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([])
);
};
Using OneBalance API Functions
For our chain-abstracted swap interface, we’ll use the OneBalance API functions we previously implemented in the setup section:This implementation uses three key endpoints:
- Get Quote - Get a quote for cross-chain swaps or transfers
- ExecuteQuote - Submit a signed quote for execution
- Check Status - Track the transaction status
Creating the Dashboard Page
Now, let’s create a dashboard that allows users to swap USDC for ETH (and vice versa) with built-in transaction status polling:dashboard.tsx
Copy
Ask AI
// src/app/dashboard/page.tsx
'use client';
import { useEffect, useState, useRef, ChangeEvent } from 'react';
import { usePrivy, useWallets } from '@privy-io/react-auth';
import { getQuote, executeQuote, checkTransactionStatus, predictAccountAddress, getAggregatedBalance } from '@/lib/onebalance';
import { Quote } from '@/lib/types/quote';
import { signQuote } from '@/lib/privySigningUtils';
import { formatUnits } from 'viem';
import { useRouter } from 'next/navigation';
export default function Dashboard() {
const router = useRouter();
const { user, ready, authenticated, logout } = usePrivy();
const { wallets } = useWallets();
const [loading, setLoading] = useState(false);
const [swapping, setSwapping] = useState(false);
const [status, setStatus] = useState<any>(null);
const [accountAddress, setAccountAddress] = useState<string | null>(null);
const [usdcBalance, setUsdcBalance] = useState<string | null>(null);
const [ethBalance, setEthBalance] = useState<string | null>(null);
const [usdcChainBalances, setUsdcChainBalances] = useState<Array<{chainId: string, balance: string, numericBalance: number, assetType: string}>>([]);
const [ethChainBalances, setEthChainBalances] = useState<Array<{chainId: string, balance: string, numericBalance: number, assetType: string}>>([]);
const [quote, setQuote] = useState<Quote | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<boolean>(false);
const statusPollingRef = useRef<NodeJS.Timeout | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [swapAmount, setSwapAmount] = useState('5.00');
const [estimatedAmount, setEstimatedAmount] = useState<string | null>(null);
const [fetchingQuote, setFetchingQuote] = useState(false);
const [swapDirection, setSwapDirection] = useState<'USDC_TO_ETH' | 'ETH_TO_USDC'>('USDC_TO_ETH');
const [showUsdcChainDetails, setShowUsdcChainDetails] = useState(false);
const [showEthChainDetails, setShowEthChainDetails] = useState(false);
// Helper function to get chain name from chain ID
const getChainName = (chainId: string): string => {
const chainMap: Record<string, string> = {
'1': 'Ethereum',
'137': 'Polygon',
'42161': 'Arbitrum',
'10': 'Optimism',
'8453': 'Base',
'59144': 'Linea',
'43114': 'Avalanche'
};
return chainMap[chainId] || `Chain ${chainId}`;
};
const embeddedWallet = wallets.find(wallet => wallet.walletClientType === 'privy');
// Handle logout and redirect to home page
const handleLogout = async () => {
await logout();
router.push('/');
};
// Get OneBalance account address based on Privy wallet
useEffect(() => {
async function setupAccount() {
if (embeddedWallet && embeddedWallet.address) {
try {
// For this demo, we'll use the same address as both session and admin
const predictedAddress = await predictAccountAddress(
embeddedWallet.address,
embeddedWallet.address
);
setAccountAddress(predictedAddress);
// Get aggregated balance for USDC and ETH
fetchBalances(predictedAddress);
} catch (err) {
console.error('Error setting up account:', err);
setError('Failed to set up OneBalance account');
}
}
}
if (ready && authenticated) {
setupAccount();
}
// Clean up polling interval on unmount
return () => {
if (statusPollingRef.current) {
clearInterval(statusPollingRef.current);
}
};
}, [embeddedWallet, ready, authenticated]);
// Handle swap direction toggle
const toggleSwapDirection = () => {
// Reset current quote and estimated amount
setQuote(null);
setEstimatedAmount(null);
// Toggle direction
setSwapDirection(prevDirection =>
prevDirection === 'USDC_TO_ETH' ? 'ETH_TO_USDC' : 'USDC_TO_ETH'
);
// Reset swap amount to a sensible default based on direction
if (swapDirection === 'USDC_TO_ETH') {
// Switching to ETH -> USDC, set a default ETH amount (0.001 ETH)
setSwapAmount('0.001');
} else {
// Switching to USDC -> ETH, set a default USDC amount (5 USDC)
setSwapAmount('5.00');
}
// Fetch a new quote after a brief delay to ensure state is updated
setTimeout(() => {
if (accountAddress && embeddedWallet) {
fetchEstimatedQuote(swapDirection === 'USDC_TO_ETH' ? '0.001' : '5.00');
}
}, 300);
};
// Handle swap amount change
const handleSwapAmountChange = async (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (isNaN(Number(value)) || Number(value) <= 0) {
return;
}
setSwapAmount(value);
// Reset estimated amount when input changes
setEstimatedAmount(null);
// If we have a valid amount and an account, fetch a new quote
if (Number(value) > 0 && accountAddress && embeddedWallet) {
fetchEstimatedQuote(value);
}
};
// Fetch a quote for estimation purposes
const fetchEstimatedQuote = async (amountStr: string) => {
if (!accountAddress || !embeddedWallet) return;
try {
setFetchingQuote(true);
// Convert to smallest unit based on direction
// USDC has 6 decimals, ETH has 18 decimals
const amount = swapDirection === 'USDC_TO_ETH'
? (parseFloat(amountStr) * 1_000_000).toString() // USDC -> ETH (6 decimals)
: (parseFloat(amountStr) * 1e18).toString(); // ETH -> USDC (18 decimals)
const quoteRequest = {
from: {
account: {
sessionAddress: embeddedWallet.address,
adminAddress: embeddedWallet.address,
accountAddress: accountAddress,
},
asset: {
assetId: swapDirection === 'USDC_TO_ETH' ? 'ob:usdc' : 'ob:eth',
},
amount,
},
to: {
asset: {
assetId: swapDirection === 'USDC_TO_ETH' ? 'ob:eth' : 'ob:usdc',
},
},
};
const quoteResponse = await getQuote(quoteRequest);
setQuote(quoteResponse);
// Extract estimated amount from the quote
if (quoteResponse.destinationToken && quoteResponse.destinationToken.amount) {
// Format based on direction
if (swapDirection === 'USDC_TO_ETH') {
// USDC -> ETH (ETH has 18 decimals)
const ethAmount = parseFloat(formatUnits(BigInt(quoteResponse.destinationToken.amount), 18)).toFixed(6);
setEstimatedAmount(ethAmount);
} else {
// ETH -> USDC (USDC has 6 decimals)
const usdcAmount = parseFloat(formatUnits(BigInt(quoteResponse.destinationToken.amount), 6)).toFixed(2);
setEstimatedAmount(usdcAmount);
}
}
} catch (err) {
console.error('Error fetching quote for estimation:', err);
setEstimatedAmount(null);
} finally {
setFetchingQuote(false);
}
};
// Fetch USDC and ETH balances
const fetchBalances = async (address: string) => {
try {
const balanceData = await getAggregatedBalance(address);
// Find USDC in the balance data
const usdcAsset = balanceData.balanceByAggregatedAsset.find(
(asset: any) => asset.aggregatedAssetId === 'ob:usdc'
);
// Find ETH in the balance data
const ethAsset = balanceData.balanceByAggregatedAsset.find(
(asset: any) => asset.aggregatedAssetId === 'ob:eth'
);
if (usdcAsset) {
// Format the balance (USDC has 6 decimals)
const formattedBalance = parseFloat(formatUnits(BigInt(usdcAsset.balance), 6)).toFixed(2);
setUsdcBalance(formattedBalance);
// Extract individual chain balances for USDC
if (usdcAsset.individualAssetBalances && usdcAsset.individualAssetBalances.length > 0) {
const chainBalances = usdcAsset.individualAssetBalances
.map((chainBalance: any) => {
// Extract chain ID from assetType (format: eip155:CHAIN_ID/...)
const chainId = chainBalance.assetType.split(':')[1].split('/')[0];
const formattedBalance = parseFloat(formatUnits(BigInt(chainBalance.balance), 6)).toFixed(2);
return {
chainId,
balance: formattedBalance,
numericBalance: parseFloat(formattedBalance), // For sorting
assetType: chainBalance.assetType
};
})
// Filter out zero balances
.filter((chainBalance: {numericBalance: number}) => chainBalance.numericBalance > 0)
// Sort by balance in descending order
.sort((a: {numericBalance: number}, b: {numericBalance: number}) => b.numericBalance - a.numericBalance);
setUsdcChainBalances(chainBalances);
}
} else {
setUsdcBalance('0.00');
setUsdcChainBalances([]);
}
if (ethAsset) {
// Format the balance (ETH has 18 decimals)
const formattedBalance = parseFloat(formatUnits(BigInt(ethAsset.balance), 18)).toFixed(6);
setEthBalance(formattedBalance);
// Extract individual chain balances for ETH
if (ethAsset.individualAssetBalances && ethAsset.individualAssetBalances.length > 0) {
const chainBalances = ethAsset.individualAssetBalances
.map((chainBalance: any) => {
// Extract chain ID from assetType (format: eip155:CHAIN_ID/...)
const chainId = chainBalance.assetType.split(':')[1].split('/')[0];
const formattedBalance = parseFloat(formatUnits(BigInt(chainBalance.balance), 18)).toFixed(6);
return {
chainId,
balance: formattedBalance,
numericBalance: parseFloat(formattedBalance), // For sorting
assetType: chainBalance.assetType
};
})
// Filter out zero balances
.filter((chainBalance: {numericBalance: number}) => chainBalance.numericBalance > 0)
// Sort by balance in descending order
.sort((a: {numericBalance: number}, b: {numericBalance: number}) => b.numericBalance - a.numericBalance);
setEthChainBalances(chainBalances);
}
} else {
setEthBalance('0.000000');
setEthChainBalances([]);
}
// After getting balances, fetch an initial quote estimate using the default amount
if (address && embeddedWallet) {
fetchEstimatedQuote(swapAmount);
}
} catch (err) {
console.error('Error fetching balances:', err);
setUsdcBalance('0.00');
setEthBalance('0.000000');
setUsdcChainBalances([]);
setEthChainBalances([]);
}
};
// Poll for transaction status
const startStatusPolling = (quoteId: string) => {
if (statusPollingRef.current) {
clearInterval(statusPollingRef.current);
}
setIsPolling(true);
statusPollingRef.current = setInterval(async () => {
try {
const statusData = await checkTransactionStatus(quoteId);
setStatus(statusData);
// If the transaction is completed or failed, stop polling
if (statusData.status === 'COMPLETED' || statusData.status === 'FAILED') {
if (statusPollingRef.current) {
clearInterval(statusPollingRef.current);
setIsPolling(false);
}
// Refresh balances after transaction is completed
if (accountAddress && statusData.status === 'COMPLETED') {
fetchBalances(accountAddress);
}
}
} catch (err) {
console.error('Error polling transaction status:', err);
if (statusPollingRef.current) {
clearInterval(statusPollingRef.current);
setIsPolling(false);
}
}
}, 1000); // Poll every 1 second
};
// Request and execute a chain-abstracted swap
const handleSwap = async () => {
if (!accountAddress || !embeddedWallet) {
setError('Wallet not connected or OneBalance account not set up');
return;
}
setLoading(true);
setSwapping(true);
setError(null);
setSuccess(false);
try {
// Use already fetched quote if available
let quoteResponse = quote;
// If no quote available or it's stale, fetch a new one
if (!quoteResponse) {
// Convert to smallest unit based on direction
const amount = swapDirection === 'USDC_TO_ETH'
? (parseFloat(swapAmount) * 1_000_000).toString() // USDC -> ETH (6 decimals)
: (parseFloat(swapAmount) * 1e18).toString(); // ETH -> USDC (18 decimals)
const quoteRequest = {
from: {
account: {
sessionAddress: embeddedWallet.address,
adminAddress: embeddedWallet.address,
accountAddress: accountAddress,
},
asset: {
assetId: swapDirection === 'USDC_TO_ETH' ? 'ob:usdc' : 'ob:eth',
},
amount,
},
to: {
asset: {
assetId: swapDirection === 'USDC_TO_ETH' ? 'ob:eth' : 'ob:usdc',
},
},
};
quoteResponse = await getQuote(quoteRequest);
setQuote(quoteResponse);
}
if (!quoteResponse) {
throw new Error('Failed to get a quote for the swap');
}
// Step 2: Sign the quote
const signedQuote = await signQuote(quoteResponse, embeddedWallet);
// Step 3: Execute the quote
setLoading(true);
const executionResponse = await executeQuote(signedQuote);
// Step 4: Start polling for transaction status
startStatusPolling(quoteResponse.id);
setSuccess(true);
setLoading(false);
} catch (err: any) {
console.error('Error in swap process:', err);
setError(err.message || 'Failed to complete swap');
setLoading(false);
} finally {
setSwapping(false);
}
};
if (!ready || !authenticated) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#FFAB40]"></div>
</div>
);
}
return (
<main className="flex min-h-screen flex-col items-center text-black p-4 md:p-24">
<div className="max-w-lg w-full bg-white p-8 rounded-xl shadow-md">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">OneBalance Dashboard</h1>
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-gray-700"
>
Logout
</button>
</div>
<div className="mb-6">
<div className="text-sm text-gray-500 mb-1">Connected as</div>
<div className="font-medium truncate">
{user?.email?.address || embeddedWallet?.address || 'Anonymous'}
</div>
</div>
{/* Account Info Section */}
<div className="bg-gray-50 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4">Account Information</h2>
<div className="space-y-3">
<div>
<div className="text-gray-500 text-sm mb-1">OneBalance Smart Account</div>
<div className="font-semibold text-sm break-all">
{accountAddress || <div className="animate-pulse bg-gray-200 h-4 w-12 rounded"></div>}
</div>
</div>
<div className="flex flex-row space-x-4">
<div className="flex-1">
<div className="text-gray-500 text-sm mb-1">USDC Balance</div>
<div className="font-medium text-xl mb-1">
{usdcBalance ? `${usdcBalance} USDC` : <div className="animate-pulse bg-gray-200 h-4 w-12 rounded"></div>}
</div>
<div className="text-xs text-gray-500 flex items-center cursor-pointer"
onClick={() => setShowUsdcChainDetails(!showUsdcChainDetails)}>
{showUsdcChainDetails ? "Hide chain details" : "Show chain details"}
<svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 ml-1 transition-transform ${showUsdcChainDetails ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{showUsdcChainDetails && (
<div className="mt-2 space-y-1 text-xs">
{usdcChainBalances.length > 0 ? (
usdcChainBalances.map((chainBalance, idx) => (
<div key={`usdc-${idx}`} className="flex justify-between border-b border-gray-100 pb-1">
<span>{getChainName(chainBalance.chainId)}</span>
<span>{chainBalance.balance} USDC</span>
</div>
))
) : (
<div className="text-gray-400">No chain data available</div>
)}
</div>
)}
</div>
<div className="flex-1">
<div className="text-gray-500 text-sm mb-1">ETH Balance</div>
<div className="font-medium text-xl mb-1">
{ethBalance ? `${ethBalance} ETH` : <div className="animate-pulse bg-gray-200 h-4 w-12 rounded"></div>}
</div>
<div className="text-xs text-gray-500 flex items-center cursor-pointer"
onClick={() => setShowEthChainDetails(!showEthChainDetails)}>
{showEthChainDetails ? "Hide chain details" : "Show chain details"}
<svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 ml-1 transition-transform ${showEthChainDetails ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{showEthChainDetails && (
<div className="mt-2 space-y-1 text-xs">
{ethChainBalances.length > 0 ? (
ethChainBalances.map((chainBalance, idx) => (
<div key={`eth-${idx}`} className="flex justify-between border-b border-gray-100 pb-1">
<span>{getChainName(chainBalance.chainId)}</span>
<span>{chainBalance.balance} ETH</span>
</div>
))
) : (
<div className="text-gray-400">No chain data available</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="bg-gray-50 p-6 rounded-lg mb-6">
<h2 className="text-xl font-semibold mb-4">Chain-Abstracted Swap</h2>
<div className="flex items-center justify-between mb-4">
<div className="text-center p-3 bg-white rounded-md shadow-sm w-5/12">
<div className="text-gray-500 text-sm mb-1">From</div>
<div className="font-medium">
<input
type="text"
value={swapAmount}
onChange={handleSwapAmountChange}
className="w-20 text-center border-b border-gray-300 focus:outline-none focus:border-[#FFAB40]"
/> {swapDirection === 'USDC_TO_ETH' ? 'USDC' : 'ETH'}
</div>
<div className="text-xs text-gray-400">on any chain</div>
</div>
<div
className="text-[#FFAB40] cursor-pointer hover:text-[#FF9800] hover:scale-125 transition-all duration-200"
onClick={toggleSwapDirection}
title="Reverse swap direction"
>
↔
</div>
<div className="text-center p-3 bg-white rounded-md shadow-sm w-5/12">
<div className="text-gray-500 text-sm mb-1">To</div>
<div className="font-medium">
{fetchingQuote ? (
<div className="inline-block w-12 h-4">
<div className="animate-pulse bg-gray-200 h-4 w-12 rounded"></div>
</div>
) : estimatedAmount ? (
`${estimatedAmount} ${swapDirection === 'USDC_TO_ETH' ? 'ETH' : 'USDC'}`
) : (
swapDirection === 'USDC_TO_ETH' ? 'ETH' : 'USDC'
)}
</div>
<div className="text-xs text-gray-400">on any chain</div>
</div>
</div>
<button
onClick={handleSwap}
disabled={loading || !accountAddress || parseFloat(swapAmount) <= 0}
className={`w-full py-3 px-4 rounded-lg font-medium transition-all ${
loading || parseFloat(swapAmount) <= 0
? 'bg-gray-300 cursor-not-allowed'
: 'bg-[#FFAB40] text-white hover:bg-[#FF9800]'
}`}
>
{loading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
{swapping ? 'Swapping...' : 'Processing...'}
</div>
) : (
'Swap Now'
)}
</button>
<div className="mt-3 text-xs text-gray-500 text-center">
No gas tokens needed - pay fees with your {swapDirection === 'USDC_TO_ETH' ? 'USDC' : 'ETH'} balance!
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-700 rounded-lg">
<p className="font-medium">Error</p>
<p className="text-sm">{error}</p>
</div>
)}
{success && (
<div className="mb-6 p-4 bg-green-50 text-green-700 rounded-lg">
<p className="font-medium">Success!</p>
<p className="text-sm">
Your chain-abstracted swap has been initiated.
{isPolling && ' Monitoring transaction status...'}
</p>
</div>
)}
{status && (
<div className="bg-gray-50 p-6 rounded-lg">
<h3 className="text-lg font-semibold mb-3">Transaction Status</h3>
<div className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-gray-500">Status:</span>
<span className={`font-medium ${
status.status === 'COMPLETED' ? 'text-green-600' :
status.status === 'FAILED' ? 'text-red-600' : 'text-yellow-600'
}`}>
{status.status || 'Pending'}
</span>
</div>
{status.originChainOperations?.length > 0 && (
<div className="flex justify-between">
<span className="text-gray-500">Origin Chain:</span>
<a
href={status.originChainOperations[0].explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline truncate ml-2 max-w-[200px]"
>
View Transaction
</a>
</div>
)}
{status.destinationChainOperations?.length > 0 && (
<div className="flex justify-between">
<span className="text-gray-500">Destination Chain:</span>
<a
href={status.destinationChainOperations[0].explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline truncate ml-2 max-w-[200px]"
>
View Transaction
</a>
</div>
)}
</div>
</div>
)}
</div>
</main>
);
}
Understanding the Chain-Abstracted Swap Flow
This improved implementation offers several key enhancements over the basic version:-
Bidirectional Swapping
- Users can swap from USDC → ETH or ETH → USDC
- The UI dynamically updates based on the selected direction
-
Real-Time Quote Estimation
- When users input an amount, a quote is fetched to show the estimated output
- This provides transparency about the expected exchange rate
-
Balance Display
- Shows users their current USDC and ETH balances from across all chains
- Updates balances automatically after a successful swap
-
Robust Transaction Polling
- Implements a proper polling mechanism to track transaction status
- Handles different transaction states (pending, completed, failed)
- Automatically stops polling when the transaction completes or fails
-
Error Handling & Recovery
- Provides clear error messages when issues occur
- Implements clean-up logic to prevent resource leaks (clearing intervals)
-
Enhanced User Experience
- Shows loading indicators during swap operations
- Provides visual feedback for transaction states
- Includes links to block explorers to view transactions
Multichain Asset Visibility
One of the most powerful aspects of this implementation is how it handles multichain asset visibility. When users interact with the application:- They see their USDC and ETH balances as a single aggregated total across all supported chains
- They can select “from” and “to” assets without needing to specify which chain they’re on
- The underlying complexity of potentially interacting with multiple blockchains is completely hidden
Key Technical Implementations
1. Transaction Status Polling
status-polling.ts
Copy
Ask AI
// Poll for transaction status
const startStatusPolling = (quoteId: string) => {
if (statusPollingRef.current) {
clearInterval(statusPollingRef.current);
}
setIsPolling(true);
statusPollingRef.current = setInterval(async () => {
try {
const statusData = await checkTransactionStatus(quoteId);
setStatus(statusData);
// If the transaction is completed or failed, stop polling
if (statusData.status.status === 'COMPLETED' || statusData.status.status === 'FAILED') {
if (statusPollingRef.current) {
clearInterval(statusPollingRef.current);
setIsPolling(false);
}
// Refresh balances after transaction is completed
if (accountAddress && statusData.status.status === 'COMPLETED') {
fetchBalances(accountAddress);
}
}
} catch (err) {
console.error('Error polling transaction status:', err);
if (statusPollingRef.current) {
clearInterval(statusPollingRef.current);
setIsPolling(false);
}
}
}, 1000); // Poll every 1 second
};
This polling mechanism uses the Get Execution
Status endpoint to track
transaction progress in real-time.
2. Signing with Privy
A critical part of the implementation is securely signing operations using the Privy wallet. The signing process handles:- Converting the typed data format required by EIP-712
- Managing the signature process using the embedded wallet
- Ensuring all operations in a quote are properly signed
- Handling both origin and destination chain operations
swap-flow.ts
Copy
Ask AI
// Example of the signing flow
const handleSwap = async () => {
// ... other code
// Get the quote from OneBalance
const quoteResponse = await getQuote(quoteRequest);
// Sign the quote using the Privy wallet
const signedQuote = await signQuote(quoteResponse, embeddedWallet);
// Execute the signed quote
await executeQuote(signedQuote);
// ... other code
};
3. Balance Checking Across Chains
balance-checker.ts
Copy
Ask AI
// Fetch USDC and ETH balances
const fetchBalances = async (address: string) => {
try {
const balanceData = await getAggregatedBalance(address);
// Find USDC in the balance data
const usdcAsset = balanceData.balanceByAggregatedAsset.find(
(asset: any) => asset.aggregatedAssetId === 'ob:usdc'
);
// Find ETH in the balance data
const ethAsset = balanceData.balanceByAggregatedAsset.find(
(asset: any) => asset.aggregatedAssetId === 'ob:eth'
);
if (usdcAsset) {
// Format the balance (USDC has 6 decimals)
const formattedBalance = parseFloat(formatUnits(BigInt(usdcAsset.balance), 6)).toFixed(2);
setUsdcBalance(formattedBalance);
}
if (ethAsset) {
// Format the balance (ETH has 18 decimals)
const formattedBalance = parseFloat(formatUnits(BigInt(ethAsset.balance), 18)).toFixed(6);
setEthBalance(formattedBalance);
}
} catch (err) {
console.error('Error fetching balances:', err);
}
};
This function leverages the Aggregated
Balance endpoint to
retrieve all token balances across multiple chains with a single API call.
What Makes This Implementation Powerful?
-
Chain Abstraction
- Users can swap tokens across chains without even knowing which chains are involved
- The UI treats USDC and ETH as unified assets regardless of their underlying chains
-
Robust Error Handling
- Handles API errors gracefully
- Provides clear feedback to users
- Implements proper cleanup of resources
-
Real-Time Transaction Tracking
- Users can see the status of their transactions as they progress
- Provides links to block explorers for deeper inspection
- Automatically refreshes balances when transactions complete
-
Bidirectional Swapping
- Users can swap in either direction (USDC → ETH or ETH → USDC)
- The UI adapts dynamically to the selected direction
-
Gas Abstraction
- Users can pay transaction fees with the token they’re swapping
- No need to hold native gas tokens on each chain