본문으로 건너뛰기

Multi-Chain dApp Architecture Guide

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.

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

// 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

// 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

// 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' };
}
}
}
// 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<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

// 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

// 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

// 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

// 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

// 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

// 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

// __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

// __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