Skip to main content

Multi-Chain dApp Architecture Guide

Complete guide to building multi-chain decentralized applications with unified state management, cross-chain operations, and seamless user experience.

Share
Last updated on October 5, 2026
On this page

This comprehensive guide covers building robust multi-chain decentralized applications that can interact with multiple blockchains simultaneously, providing users with a seamless cross-chain experience — the patterns below apply directly when your dApp talks to several chains through bex router's unified API.

Architecture Overview​

Multi-chain dApps face unique challenges including:

  • State Management: Coordinating data across different blockchains
  • Transaction Coordination: Managing operations across multiple networks
  • Error Handling: Dealing with varying failure modes
  • User Experience: Providing unified interface for different chains
  • Performance: Optimizing for multiple network latencies

Core Architecture Patterns​

1. Chain Abstraction Layer​

typescript
// types/blockchain.ts
export enum SupportedChain {
  SUI = 'sui',
  APTOS = 'aptos',
  ETHEREUM = 'ethereum',
  POLYGON = 'polygon',
}
 
export interface ChainConfig {
  chainId: string;
  name: string;
  rpcUrl: string;
  nativeCurrency: {
    name: string;
    symbol: string;
    decimals: number;
  };
  blockExplorer?: string;
}
 
export interface BlockchainClient {
  getBalance(address: string): Promise<string>;
  sendTransaction(tx: any): Promise<string>;
  getTransactionStatus(hash: string): Promise<TransactionStatus>;
  estimateGas?(tx: any): Promise<string>;
}
 
export interface TransactionStatus {
  hash: string;
  status: 'pending' | 'success' | 'failed';
  blockNumber?: number;
  gasUsed?: string;
}

2. Unified Client Manager​

typescript
// clients/ChainManager.ts
import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';
import { Aptos, AptosConfig, Network } from '@aptos-labs/ts-sdk';
import { SupportedChain, ChainConfig, BlockchainClient } from '../types/blockchain';
 
export class ChainManager {
  private clients: Map<SupportedChain, BlockchainClient> = new Map();
  private configs: Map<SupportedChain, ChainConfig> = new Map();
 
  constructor() {
    this.initializeChains();
  }
 
  private initializeChains() {
    // Initialize Sui
    const suiConfig: ChainConfig = {
      chainId: 'sui-mainnet',
      name: 'Sui Mainnet',
      rpcUrl: process.env.NEXT_PUBLIC_SUI_RPC_URL || getFullnodeUrl('mainnet'),
      nativeCurrency: { name: 'SUI', symbol: 'SUI', decimals: 9 },
      blockExplorer: 'https://suiscan.xyz',
    };
    
    this.configs.set(SupportedChain.SUI, suiConfig);
    this.clients.set(SupportedChain.SUI, new SuiClientAdapter(suiConfig.rpcUrl));
 
    // Initialize Aptos
    const aptosConfig: ChainConfig = {
      chainId: 'aptos-mainnet',
      name: 'Aptos Mainnet',
      rpcUrl: process.env.NEXT_PUBLIC_APTOS_RPC_URL || 'https://fullnode.mainnet.aptoslabs.com/v1',
      nativeCurrency: { name: 'APT', symbol: 'APT', decimals: 8 },
      blockExplorer: 'https://aptoscan.com',
    };
    
    this.configs.set(SupportedChain.APTOS, aptosConfig);
    this.clients.set(SupportedChain.APTOS, new AptosClientAdapter(aptosConfig.rpcUrl));
  }
 
  getClient(chain: SupportedChain): BlockchainClient {
    const client = this.clients.get(chain);
    if (!client) {
      throw new Error(`Client not initialized for chain: ${chain}`);
    }
    return client;
  }
 
  getConfig(chain: SupportedChain): ChainConfig {
    const config = this.configs.get(chain);
    if (!config) {
      throw new Error(`Config not found for chain: ${chain}`);
    }
    return config;
  }
 
  getSupportedChains(): SupportedChain[] {
    return Array.from(this.clients.keys());
  }
 
  async getBalanceAcrossChains(address: string): Promise<Record<SupportedChain, string>> {
    const balances: Record<string, string> = {};
    
    const promises = Array.from(this.clients.entries()).map(async ([chain, client]) => {
      try {
        const balance = await client.getBalance(address);
        balances[chain] = balance;
      } catch (error) {
        console.error(`Error fetching balance for ${chain}:`, error);
        balances[chain] = '0';
      }
    });
 
    await Promise.allSettled(promises);
    return balances as Record<SupportedChain, string>;
  }
}

3. Chain-Specific Adapters​

typescript
// clients/SuiClientAdapter.ts
import { SuiClient } from '@mysten/sui/client';
import { BlockchainClient, TransactionStatus } from '../types/blockchain';
 
export class SuiClientAdapter implements BlockchainClient {
  private client: SuiClient;
 
  constructor(rpcUrl: string) {
    this.client = new SuiClient({ url: rpcUrl });
  }
 
  async getBalance(address: string): Promise<string> {
    try {
      const balance = await this.client.getBalance({ owner: address });
      return balance.totalBalance;
    } catch (error) {
      console.error('Error fetching Sui balance:', error);
      return '0';
    }
  }
 
  async sendTransaction(tx: any): Promise<string> {
    try {
      const result = await this.client.signAndExecuteTransaction({
        transaction: tx.transaction,
        signer: tx.signer,
      });
      return result.digest;
    } catch (error) {
      console.error('Error sending Sui transaction:', error);
      throw error;
    }
  }
 
  async getTransactionStatus(hash: string): Promise<TransactionStatus> {
    try {
      const result = await this.client.getTransactionBlock({ digest: hash });
      
      return {
        hash,
        status: result.effects?.status?.status === 'success' ? 'success' : 'failed',
        blockNumber: Number(result.checkpoint),
        gasUsed: result.effects?.gasUsed?.computationCost,
      };
    } catch (error) {
      console.error('Error fetching Sui transaction status:', error);
      return { hash, status: 'pending' };
    }
  }
}
typescript
// clients/AptosClientAdapter.ts
import { Aptos, AptosConfig } from '@aptos-labs/ts-sdk';
import { BlockchainClient, TransactionStatus } from '../types/blockchain';
 
export class AptosClientAdapter implements BlockchainClient {
  private client: Aptos;
 
  constructor(rpcUrl: string) {
    this.client = new Aptos(new AptosConfig({ fullnode: rpcUrl }));
  }
 
  async getBalance(address: string): Promise<string> {
    try {
      const resources = await this.client.getAccountResources({
        accountAddress: address,
      });
      
      const coinStore = resources.find(
        (resource) => resource.type === '0x1::coin::CoinStore&lt;0x1::aptos_coin::AptosCoin>'
      );
      
      if (coinStore?.data) {
        return (coinStore.data as any).coin.value;
      }
      return '0';
    } catch (error) {
      console.error('Error fetching Aptos balance:', error);
      return '0';
    }
  }
 
  async sendTransaction(tx: any): Promise<string> {
    try {
      const result = await this.client.signAndSubmitTransaction({
        signer: tx.signer,
        transaction: tx.transaction,
      });
      
      await this.client.waitForTransaction({ transactionHash: result.hash });
      return result.hash;
    } catch (error) {
      console.error('Error sending Aptos transaction:', error);
      throw error;
    }
  }
 
  async getTransactionStatus(hash: string): Promise<TransactionStatus> {
    try {
      const result = await this.client.getTransactionByHash({ transactionHash: hash });
      
      return {
        hash,
        status: result.success ? 'success' : 'failed',
        blockNumber: Number(result.version),
        gasUsed: result.gas_used,
      };
    } catch (error) {
      console.error('Error fetching Aptos transaction status:', error);
      return { hash, status: 'pending' };
    }
  }
 
  async estimateGas(tx: any): Promise<string> {
    try {
      const simulation = await this.client.transaction.simulate.simple({
        signerPublicKey: tx.signer.publicKey,
        transaction: tx.transaction,
      });
      
      return simulation[0].gas_used || '0';
    } catch (error) {
      console.error('Error estimating Aptos gas:', error);
      return '0';
    }
  }
}

State Management Architecture​

1. Multi-Chain Store​

typescript
// store/multiChainStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { SupportedChain } from '../types/blockchain';
 
interface ChainState {
  selectedChain: SupportedChain;
  connectedChains: Set<SupportedChain>;
  balances: Record<SupportedChain, string>;
  transactions: Record<SupportedChain, TransactionHistory[]>;
  isLoading: Record<SupportedChain, boolean>;
  errors: Record<SupportedChain, string | null>;
}
 
interface ChainActions {
  setSelectedChain: (chain: SupportedChain) => void;
  addConnectedChain: (chain: SupportedChain) => void;
  removeConnectedChain: (chain: SupportedChain) => void;
  updateBalance: (chain: SupportedChain, balance: string) => void;
  addTransaction: (chain: SupportedChain, tx: TransactionHistory) => void;
  setLoading: (chain: SupportedChain, loading: boolean) => void;
  setError: (chain: SupportedChain, error: string | null) => void;
  clearErrors: () => void;
}
 
interface TransactionHistory {
  hash: string;
  type: string;
  amount: string;
  timestamp: number;
  status: 'pending' | 'success' | 'failed';
}
 
export const useMultiChainStore = create<ChainState & ChainActions>()(
  devtools(
    persist(
      (set, get) => ({
        // Initial state
        selectedChain: SupportedChain.SUI,
        connectedChains: new Set(),
        balances: {} as Record<SupportedChain, string>,
        transactions: {} as Record<SupportedChain, TransactionHistory[]>,
        isLoading: {} as Record<SupportedChain, boolean>,
        errors: {} as Record<SupportedChain, string | null>,
 
        // Actions
        setSelectedChain: (chain) => set({ selectedChain: chain }),
        
        addConnectedChain: (chain) =>
          set((state) => ({
            connectedChains: new Set([...state.connectedChains, chain]),
          })),
        
        removeConnectedChain: (chain) =>
          set((state) => {
            const newConnected = new Set(state.connectedChains);
            newConnected.delete(chain);
            return { connectedChains: newConnected };
          }),
        
        updateBalance: (chain, balance) =>
          set((state) => ({
            balances: { ...state.balances, [chain]: balance },
          })),
        
        addTransaction: (chain, tx) =>
          set((state) => ({
            transactions: {
              ...state.transactions,
              [chain]: [tx, ...(state.transactions[chain] || [])].slice(0, 50), // Keep latest 50
            },
          })),
        
        setLoading: (chain, loading) =>
          set((state) => ({
            isLoading: { ...state.isLoading, [chain]: loading },
          })),
        
        setError: (chain, error) =>
          set((state) => ({
            errors: { ...state.errors, [chain]: error },
          })),
        
        clearErrors: () => set({ errors: {} as Record<SupportedChain, string | null> }),
      }),
      {
        name: 'multi-chain-store',
        partialize: (state) => ({
          selectedChain: state.selectedChain,
          transactions: state.transactions,
        }),
      }
    ),
    { name: 'multi-chain-store' }
  )
);

2. Multi-Chain React Hooks​

typescript
// hooks/useMultiChain.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useMultiChainStore } from '../store/multiChainStore';
import { ChainManager } from '../clients/ChainManager';
import { SupportedChain } from '../types/blockchain';
 
const chainManager = new ChainManager();
 
export function useMultiChainBalances(address?: string) {
  const { connectedChains, updateBalance, setLoading, setError } = useMultiChainStore();
 
  return useQuery({
    queryKey: ['multichain-balances', address, Array.from(connectedChains)],
    queryFn: async () => {
      if (!address) return {};
 
      const results: Record<SupportedChain, string> = {} as Record<SupportedChain, string>;
      
      for (const chain of connectedChains) {
        try {
          setLoading(chain, true);
          const client = chainManager.getClient(chain);
          const balance = await client.getBalance(address);
          results[chain] = balance;
          updateBalance(chain, balance);
          setError(chain, null);
        } catch (error) {
          console.error(`Error fetching balance for ${chain}:`, error);
          setError(chain, error instanceof Error ? error.message : 'Unknown error');
          results[chain] = '0';
        } finally {
          setLoading(chain, false);
        }
      }
 
      return results;
    },
    enabled: !!address && connectedChains.size > 0,
    refetchInterval: 10000, // Refetch every 10 seconds
  });
}
 
export function useMultiChainTransaction() {
  const queryClient = useQueryClient();
  const { addTransaction, setError } = useMultiChainStore();
 
  return useMutation({
    mutationFn: async ({
      chain,
      transaction,
      type,
      amount,
    }: {
      chain: SupportedChain;
      transaction: any;
      type: string;
      amount: string;
    }) => {
      const client = chainManager.getClient(chain);
      const hash = await client.sendTransaction(transaction);
      
      // Add to transaction history
      addTransaction(chain, {
        hash,
        type,
        amount,
        timestamp: Date.now(),
        status: 'pending',
      });
 
      return { hash, chain };
    },
    onSuccess: (data) => {
      // Invalidate balance queries to refresh data
      queryClient.invalidateQueries({ queryKey: ['multichain-balances'] });
      setError(data.chain, null);
    },
    onError: (error, variables) => {
      setError(variables.chain, error instanceof Error ? error.message : 'Transaction failed');
    },
  });
}
 
export function useChainSwitcher() {
  const { selectedChain, setSelectedChain, connectedChains } = useMultiChainStore();
 
  const switchChain = (chain: SupportedChain) => {
    if (connectedChains.has(chain)) {
      setSelectedChain(chain);
    } else {
      throw new Error(`Chain ${chain} is not connected`);
    }
  };
 
  return {
    selectedChain,
    switchChain,
    availableChains: Array.from(connectedChains),
  };
}

Cross-Chain Operations​

1. Cross-Chain Bridge Integration​

typescript
// services/CrossChainBridge.ts
import { SupportedChain } from '../types/blockchain';
 
export interface BridgeOperation {
  id: string;
  sourceChain: SupportedChain;
  targetChain: SupportedChain;
  asset: string;
  amount: string;
  status: 'initiated' | 'pending' | 'completed' | 'failed';
  sourceTxHash?: string;
  targetTxHash?: string;
  estimatedTime: number; // in minutes
}
 
export class CrossChainBridge {
  private operations: Map<string, BridgeOperation> = new Map();
 
  async initiateBridge({
    sourceChain,
    targetChain,
    asset,
    amount,
    sourceAddress,
    targetAddress,
  }: {
    sourceChain: SupportedChain;
    targetChain: SupportedChain;
    asset: string;
    amount: string;
    sourceAddress: string;
    targetAddress: string;
  }): Promise<BridgeOperation> {
    
    const operationId = `bridge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    
    const operation: BridgeOperation = {
      id: operationId,
      sourceChain,
      targetChain,
      asset,
      amount,
      status: 'initiated',
      estimatedTime: this.getEstimatedBridgeTime(sourceChain, targetChain),
    };
 
    this.operations.set(operationId, operation);
 
    try {
      // Step 1: Lock/burn assets on source chain
      const sourceTxHash = await this.lockAssetsOnSource({
        chain: sourceChain,
        asset,
        amount,
        address: sourceAddress,
      });
 
      operation.sourceTxHash = sourceTxHash;
      operation.status = 'pending';
 
      // Step 2: Monitor source transaction and mint on target
      this.monitorBridgeOperation(operationId, targetAddress);
 
      return operation;
    } catch (error) {
      operation.status = 'failed';
      throw error;
    }
  }
 
  private async lockAssetsOnSource({
    chain,
    asset,
    amount,
    address,
  }: {
    chain: SupportedChain;
    asset: string;
    amount: string;
    address: string;
  }): Promise<string> {
    // Implementation depends on the specific bridge protocol
    // This is a simplified example
    
    if (chain === SupportedChain.SUI) {
      return this.lockAssetsOnSui(asset, amount, address);
    } else if (chain === SupportedChain.APTOS) {
      return this.lockAssetsOnAptos(asset, amount, address);
    }
    
    throw new Error(`Bridge not supported for chain: ${chain}`);
  }
 
  private async lockAssetsOnSui(asset: string, amount: string, address: string): Promise<string> {
    // Implement Sui-specific bridge logic
    // This would involve calling the bridge contract on Sui
    throw new Error('Sui bridge not implemented');
  }
 
  private async lockAssetsOnAptos(asset: string, amount: string, address: string): Promise<string> {
    // Implement Aptos-specific bridge logic
    // This would involve calling the bridge contract on Aptos
    throw new Error('Aptos bridge not implemented');
  }
 
  private monitorBridgeOperation(operationId: string, targetAddress: string) {
    const operation = this.operations.get(operationId);
    if (!operation) return;
 
    // In a real implementation, this would:
    // 1. Monitor the source transaction for confirmation
    // 2. Trigger the mint/unlock on the target chain
    // 3. Update the operation status
    
    setTimeout(() => {
      operation.status = 'completed';
      operation.targetTxHash = 'mock_target_tx_hash';
    }, operation.estimatedTime * 60 * 1000);
  }
 
  private getEstimatedBridgeTime(source: SupportedChain, target: SupportedChain): number {
    // Return estimated time in minutes based on chain pair
    const bridgeTimes: Record<string, number> = {
      'sui_aptos': 15,
      'aptos_sui': 15,
      'sui_ethereum': 30,
      'ethereum_sui': 30,
      'aptos_ethereum': 30,
      'ethereum_aptos': 30,
    };
    
    return bridgeTimes[`${source}_${target}`] || 60;
  }
 
  getBridgeOperation(operationId: string): BridgeOperation | undefined {
    return this.operations.get(operationId);
  }
 
  getAllOperations(): BridgeOperation[] {
    return Array.from(this.operations.values());
  }
}

2. Multi-Chain Portfolio Management​

typescript
// services/PortfolioManager.ts
import { SupportedChain } from '../types/blockchain';
import { ChainManager } from '../clients/ChainManager';
 
export interface PortfolioAsset {
  chain: SupportedChain;
  address: string;
  symbol: string;
  name: string;
  balance: string;
  decimals: number;
  usdValue?: string;
  pricePerToken?: string;
}
 
export interface Portfolio {
  totalUsdValue: string;
  assets: PortfolioAsset[];
  chainDistribution: Record<SupportedChain, number>; // Percentage
}
 
export class PortfolioManager {
  private chainManager: ChainManager;
  private priceCache: Map<string, number> = new Map();
 
  constructor(chainManager: ChainManager) {
    this.chainManager = chainManager;
  }
 
  async getPortfolio(address: string): Promise<Portfolio> {
    const assets: PortfolioAsset[] = [];
    let totalUsdValue = 0;
 
    // Get native assets from each chain
    for (const chain of this.chainManager.getSupportedChains()) {
      try {
        const client = this.chainManager.getClient(chain);
        const config = this.chainManager.getConfig(chain);
        const balance = await client.getBalance(address);
        
        const asset: PortfolioAsset = {
          chain,
          address: 'native',
          symbol: config.nativeCurrency.symbol,
          name: config.nativeCurrency.name,
          balance,
          decimals: config.nativeCurrency.decimals,
        };
 
        // Get USD value
        const price = await this.getTokenPrice(asset.symbol);
        if (price) {
          const balanceInTokens = Number(balance) / Math.pow(10, asset.decimals);
          asset.usdValue = (balanceInTokens * price).toFixed(2);
          asset.pricePerToken = price.toString();
          totalUsdValue += Number(asset.usdValue);
        }
 
        assets.push(asset);
      } catch (error) {
        console.error(`Error fetching portfolio for ${chain}:`, error);
      }
    }
 
    // Calculate chain distribution
    const chainDistribution: Record<SupportedChain, number> = {} as Record<SupportedChain, number>;
    for (const asset of assets) {
      const usdValue = Number(asset.usdValue || 0);
      const percentage = totalUsdValue > 0 ? (usdValue / totalUsdValue) * 100 : 0;
      chainDistribution[asset.chain] = percentage;
    }
 
    return {
      totalUsdValue: totalUsdValue.toFixed(2),
      assets,
      chainDistribution,
    };
  }
 
  private async getTokenPrice(symbol: string): Promise<number | null> {
    // Check cache first
    if (this.priceCache.has(symbol)) {
      return this.priceCache.get(symbol)!;
    }
 
    try {
      // In a real implementation, you'd use a price API like CoinGecko
      const response = await fetch(
        `https://api.coingecko.com/api/v3/simple/price?ids=${symbol.toLowerCase()}&vs_currencies=usd`
      );
      const data = await response.json();
      const price = data[symbol.toLowerCase()]?.usd;
      
      if (price) {
        this.priceCache.set(symbol, price);
        // Cache for 5 minutes
        setTimeout(() => this.priceCache.delete(symbol), 5 * 60 * 1000);
      }
      
      return price || null;
    } catch (error) {
      console.error(`Error fetching price for ${symbol}:`, error);
      return null;
    }
  }
 
  async rebalancePortfolio(
    address: string,
    targetDistribution: Record<SupportedChain, number>
  ): Promise<void> {
    const currentPortfolio = await this.getPortfolio(address);
    
    // Calculate needed rebalancing operations
    const operations: Array<{
      sourceChain: SupportedChain;
      targetChain: SupportedChain;
      amount: string;
    }> = [];
 
    for (const [chain, targetPercent] of Object.entries(targetDistribution)) {
      const currentPercent = currentPortfolio.chainDistribution[chain as SupportedChain] || 0;
      const difference = targetPercent - currentPercent;
      
      if (Math.abs(difference) > 1) { // Only rebalance if difference > 1%
        // Calculate amount to move
        const targetUsdValue = (Number(currentPortfolio.totalUsdValue) * targetPercent) / 100;
        const currentUsdValue = (Number(currentPortfolio.totalUsdValue) * currentPercent) / 100;
        const amountToMove = Math.abs(targetUsdValue - currentUsdValue).toString();
        
        if (difference > 0) {
          // Need to move assets TO this chain
          const sourceChain = this.findBestSourceChain(currentPortfolio, amountToMove);
          if (sourceChain) {
            operations.push({
              sourceChain,
              targetChain: chain as SupportedChain,
              amount: amountToMove,
            });
          }
        }
      }
    }
 
    // Execute rebalancing operations
    console.log('Rebalancing operations:', operations);
    // Implementation would execute these operations using the bridge
  }
 
  private findBestSourceChain(
    portfolio: Portfolio,
    amountNeeded: string
  ): SupportedChain | null {
    // Find the chain with the highest balance that can cover the amount needed
    const sortedAssets = portfolio.assets
      .filter(asset => Number(asset.usdValue || 0) >= Number(amountNeeded))
      .sort((a, b) => Number(b.usdValue || 0) - Number(a.usdValue || 0));
    
    return sortedAssets[0]?.chain || null;
  }
}

User Interface Components​

1. Chain Selector Component​

typescript
// components/ChainSelector.tsx
import React from 'react';
import { useChainSwitcher } from '../hooks/useMultiChain';
import { useMultiChainStore } from '../store/multiChainStore';
import { SupportedChain } from '../types/blockchain';
 
export function ChainSelector() {
  const { selectedChain, switchChain, availableChains } = useChainSwitcher();
  const { isLoading, errors } = useMultiChainStore();
 
  const chainIcons: Record<SupportedChain, string> = {
    [SupportedChain.SUI]: '/icons/sui.svg',
    [SupportedChain.APTOS]: '/icons/aptos.svg',
    [SupportedChain.ETHEREUM]: '/icons/ethereum.svg',
    [SupportedChain.POLYGON]: '/icons/polygon.svg',
  };
 
  const chainNames: Record<SupportedChain, string> = {
    [SupportedChain.SUI]: 'Sui',
    [SupportedChain.APTOS]: 'Aptos',
    [SupportedChain.ETHEREUM]: 'Ethereum',
    [SupportedChain.POLYGON]: 'Polygon',
  };
 
  return (
    <div className="chain-selector">
      <h3>Select Chain</h3>
      <div className="chain-grid">
        {availableChains.map((chain) => (
          <button
            key={chain}
            className={`chain-button ${selectedChain === chain ? 'selected' : ''} ${
              isLoading[chain] ? 'loading' : ''
            } ${errors[chain] ? 'error' : ''}`}
            onClick={() => switchChain(chain)}
            disabled={isLoading[chain]}
          >
            <img src={chainIcons[chain]} alt={chainNames[chain]} />
            <span>{chainNames[chain]}</span>
            {isLoading[chain] && <div className="spinner" />}
            {errors[chain] && <div className="error-indicator">!</div>}
          </button>
        ))}
      </div>
      
      {errors[selectedChain] && (
        <div className="error-message">
          Error: {errors[selectedChain]}
        </div>
      )}
    </div>
  );
}

2. Multi-Chain Portfolio Dashboard​

typescript
// components/PortfolioDashboard.tsx
import React, { useEffect, useState } from 'react';
import { useMultiChainBalances } from '../hooks/useMultiChain';
import { PortfolioManager, Portfolio } from '../services/PortfolioManager';
import { ChainManager } from '../clients/ChainManager';
 
interface PortfolioDashboardProps {
  address: string;
}
 
export function PortfolioDashboard({ address }: PortfolioDashboardProps) {
  const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
  const [loading, setLoading] = useState(true);
  const { data: balances } = useMultiChainBalances(address);
 
  useEffect(() => {
    const loadPortfolio = async () => {
      if (!address) return;
      
      setLoading(true);
      try {
        const chainManager = new ChainManager();
        const portfolioManager = new PortfolioManager(chainManager);
        const portfolioData = await portfolioManager.getPortfolio(address);
        setPortfolio(portfolioData);
      } catch (error) {
        console.error('Error loading portfolio:', error);
      } finally {
        setLoading(false);
      }
    };
 
    loadPortfolio();
  }, [address]);
 
  if (loading) {
    return <div className="loading">Loading portfolio...</div>;
  }
 
  if (!portfolio) {
    return <div className="error">Failed to load portfolio</div>;
  }
 
  return (
    <div className="portfolio-dashboard">
      <div className="portfolio-header">
        <h2>Multi-Chain Portfolio</h2>
        <div className="total-value">
          Total Value: ${portfolio.totalUsdValue}
        </div>
      </div>
 
      <div className="chain-distribution">
        <h3>Chain Distribution</h3>
        <div className="distribution-chart">
          {Object.entries(portfolio.chainDistribution).map(([chain, percentage]) => (
            <div key={chain} className="distribution-bar">
              <span className="chain-name">{chain}</span>
              <div className="bar-container">
                <div 
                  className="bar-fill" 
                  style={{ width: `${percentage}%` }}
                />
              </div>
              <span className="percentage">{percentage.toFixed(1)}%</span>
            </div>
          ))}
        </div>
      </div>
 
      <div className="assets-list">
        <h3>Assets</h3>
        <div className="assets-grid">
          {portfolio.assets.map((asset, index) => (
            <div key={index} className="asset-card">
              <div className="asset-info">
                <span className="asset-symbol">{asset.symbol}</span>
                <span className="asset-chain">{asset.chain}</span>
              </div>
              <div className="asset-balance">
                <span className="balance">
                  {(Number(asset.balance) / Math.pow(10, asset.decimals)).toFixed(4)}
                </span>
                {asset.usdValue && (
                  <span className="usd-value">${asset.usdValue}</span>
                )}
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Testing Multi-Chain Applications​

1. Integration Tests​

typescript
// __tests__/multiChain.test.ts
import { describe, it, expect, beforeEach } from '@jest/globals';
import { ChainManager } from '../clients/ChainManager';
import { SupportedChain } from '../types/blockchain';
 
describe('Multi-Chain Integration', () => {
  let chainManager: ChainManager;
 
  beforeEach(() => {
    chainManager = new ChainManager();
  });
 
  it('should initialize all supported chains', () => {
    const supportedChains = chainManager.getSupportedChains();
    expect(supportedChains).toContain(SupportedChain.SUI);
    expect(supportedChains).toContain(SupportedChain.APTOS);
  });
 
  it('should get client for each chain', () => {
    const suiClient = chainManager.getClient(SupportedChain.SUI);
    const aptosClient = chainManager.getClient(SupportedChain.APTOS);
    
    expect(suiClient).toBeDefined();
    expect(aptosClient).toBeDefined();
  });
 
  it('should fetch balances across chains', async () => {
    const mockAddress = '0x123';
    const balances = await chainManager.getBalanceAcrossChains(mockAddress);
    
    expect(balances).toHaveProperty(SupportedChain.SUI);
    expect(balances).toHaveProperty(SupportedChain.APTOS);
  });
});

2. Component Tests​

typescript
// __tests__/ChainSelector.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ChainSelector } from '../components/ChainSelector';
import { useChainSwitcher } from '../hooks/useMultiChain';
 
jest.mock('../hooks/useMultiChain');
const mockUseChainSwitcher = useChainSwitcher as jest.MockedFunction<typeof useChainSwitcher>;
 
describe('ChainSelector', () => {
  let queryClient: QueryClient;
 
  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: { queries: { retry: false } },
    });
  });
 
  it('should render available chains', () => {
    mockUseChainSwitcher.mockReturnValue({
      selectedChain: SupportedChain.SUI,
      switchChain: jest.fn(),
      availableChains: [SupportedChain.SUI, SupportedChain.APTOS],
    });
 
    render(
      <QueryClientProvider client={queryClient}>
        <ChainSelector />
      </QueryClientProvider>
    );
 
    expect(screen.getByText('Sui')).toBeInTheDocument();
    expect(screen.getByText('Aptos')).toBeInTheDocument();
  });
 
  it('should call switchChain when chain is clicked', () => {
    const mockSwitchChain = jest.fn();
    mockUseChainSwitcher.mockReturnValue({
      selectedChain: SupportedChain.SUI,
      switchChain: mockSwitchChain,
      availableChains: [SupportedChain.SUI, SupportedChain.APTOS],
    });
 
    render(
      <QueryClientProvider client={queryClient}>
        <ChainSelector />
      </QueryClientProvider>
    );
 
    fireEvent.click(screen.getByText('Aptos'));
    expect(mockSwitchChain).toHaveBeenCalledWith(SupportedChain.APTOS);
  });
});

Best Practices Summary​

  1. Abstract Chain Differences: Use adapters to provide unified interfaces
  2. Centralized State Management: Use a single store for multi-chain state
  3. Error Isolation: Handle chain-specific errors independently
  4. Performance Optimization: Cache data and batch operations when possible
  5. User Experience: Provide clear feedback for cross-chain operations
  6. Security: Validate operations on each chain independently
  7. Testing: Test each chain integration thoroughly
  8. Monitoring: Track operations across all chains
  9. Graceful Degradation: Handle chain outages gracefully
  10. Documentation: Document chain-specific behaviors and limitations

Next Steps​

Resources​

Was this page helpful?

One API key for 30+ chains

bex router is a single multi-chain gateway — one key, one bill, metered in compute units instead of an account per network.

Explore bex router