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

Complete demo available at onebalance-chain-abstracted-swap.vercel.app and source code on GitHub.

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:

terminal
pnpm dlx shadcn@latest init

For wallet connection and transaction signing, we will use Privy:

terminal
pnpm add @tanstack/react-query wagmi viem @privy-io/react-auth

Environment Setup

Make sure to copy .env.example into .env:

.env
NEXT_PUBLIC_API_URL=https://be.onebalance.io
NEXT_PUBLIC_API_KEY=
NEXT_PUBLIC_PRIVY_APP_ID=

Get your OneBalance API key from onebalance.io and Privy App ID from console.privy.io

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

lib/api/account.ts
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:

  1. Validation: Check user balance before requesting quotes
  2. Expiration Handling: Quotes expire in 30 seconds - implement countdown timers
  3. Auto-refresh: Automatically fetch new quotes when current ones expire
  4. 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

  1. Quote Validation: Check expiration before execution
  2. Signing: Sign all required operations using Privy
  3. Execution: Submit signed quote to OneBalance
  4. 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

  1. Error Boundaries: Implement React error boundaries for graceful failures
  2. Rate Limiting: Implement client-side rate limiting for API calls
  3. Cache Management: Cache asset data and balance information appropriately
  4. 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.