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),
};
}