From 26f782dfb4dd02affc3e3ab34d35e51af7a97016 Mon Sep 17 00:00:00 2001 From: Samarth Saxena Date: Sun, 15 Jun 2025 13:39:06 +0000 Subject: [PATCH 1/2] refactored --- packages/app/package.json | 3 + .../src/hooks/adapters/AptosChainAdapter.ts | 217 ++ .../src/hooks/adapters/ChainAdapterFactory.ts | 92 + .../app/src/hooks/adapters/EvmChainAdapter.ts | 297 +++ .../src/hooks/adapters/FuelChainAdapter.ts | 263 ++ .../src/hooks/adapters/SolanaChainAdapter.ts | 288 +++ .../src/hooks/adapters/SoonChainAdapter.ts | 200 ++ .../hooks/adapters/StarknetChainAdapter.ts | 185 ++ .../hooks/adapters/base/BaseChainAdapter.ts | 71 + .../adapters/base/ChainAdapter.interface.ts | 63 + .../app/src/hooks/useChainRace.original.ts | 2273 +++++++++++++++++ packages/app/src/hooks/useChainRace.ts | 2165 ++-------------- 12 files changed, 4196 insertions(+), 1921 deletions(-) create mode 100644 packages/app/src/hooks/adapters/AptosChainAdapter.ts create mode 100644 packages/app/src/hooks/adapters/ChainAdapterFactory.ts create mode 100644 packages/app/src/hooks/adapters/EvmChainAdapter.ts create mode 100644 packages/app/src/hooks/adapters/FuelChainAdapter.ts create mode 100644 packages/app/src/hooks/adapters/SolanaChainAdapter.ts create mode 100644 packages/app/src/hooks/adapters/SoonChainAdapter.ts create mode 100644 packages/app/src/hooks/adapters/StarknetChainAdapter.ts create mode 100644 packages/app/src/hooks/adapters/base/BaseChainAdapter.ts create mode 100644 packages/app/src/hooks/adapters/base/ChainAdapter.interface.ts create mode 100644 packages/app/src/hooks/useChainRace.original.ts diff --git a/packages/app/package.json b/packages/app/package.json index fbdac00..7883a1e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -32,6 +32,8 @@ "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.71.1", "@types/lodash": "^4.17.16", + "@wagmi/connectors": "^5.8.5", + "@wagmi/core": "^2.17.3", "@web3icons/react": "^4.0.13", "axios": "^1.8.4", "bs58": "^6.0.0", @@ -43,6 +45,7 @@ "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", "json5": "^2.2.3", + "keyv": "4.5.4", "lodash": "^4.17.21", "lucide-react": "^0.485.0", "next": "^15.3.1", diff --git a/packages/app/src/hooks/adapters/AptosChainAdapter.ts b/packages/app/src/hooks/adapters/AptosChainAdapter.ts new file mode 100644 index 0000000..248104c --- /dev/null +++ b/packages/app/src/hooks/adapters/AptosChainAdapter.ts @@ -0,0 +1,217 @@ +import { + Aptos, + AptosConfig, + Network, + TypeTagAddress, + TypeTagU64, + U64, + Account +} from "@aptos-labs/ts-sdk"; +import { BaseChainAdapter } from "./base/BaseChainAdapter"; +import type { AptosChainConfig } from "@/aptos/config"; +import type { + ChainType, + ChainBalance, + PreparedTransaction, + TransactionResult, + TransactionReceipt +} from "./base/ChainAdapter.interface"; + +export interface AptosWalletState { + account: Account | null; + address: string | null; + isReady: boolean; +} + +export class AptosChainAdapter extends BaseChainAdapter { + private aptosConfig: AptosChainConfig; + private walletState: AptosWalletState; + + constructor(config: AptosChainConfig, walletState: AptosWalletState) { + super({ + chainId: config.id, + name: config.name, + color: config.color, + logo: config.logo, + testnet: config.testnet, + layer: config.layer + }); + + this.aptosConfig = config; + this.walletState = walletState; + } + + get chainType(): ChainType { + return 'aptos'; + } + + isWalletReady(): boolean { + return this.walletState.isReady && !!this.walletState.account; + } + + getWalletAddress(): string { + return this.walletState.address || ''; + } + + async checkBalance(): Promise { + if (!this.walletState.account || !this.walletState.isReady) { + return this.createErrorBalance("Aptos wallet still loading..."); + } + + return this.withTimeout( + this.withRetry(async () => { + const aptosConfig = new AptosConfig({ + network: this.aptosConfig.network as Network, + fullnode: this.aptosConfig.rpcUrl, + }); + const aptos = new Aptos(aptosConfig); + + const balance = BigInt(await aptos.getAccountAPTAmount({ + accountAddress: this.walletState.account!.accountAddress + })); + + // Minimum balance threshold: 0.001 APT (100,000 octas since APT uses 8 decimals) + const hasBalance = balance > BigInt(100_000); + + return { + chainId: this.chainId, + balance, + hasBalance, + }; + }) + ).catch(error => this.createErrorBalance(this.formatError(error))); + } + + async prepareTransactions(count: number): Promise { + if (!this.walletState.account) { + throw new Error(`Aptos wallet not ready for ${this.aptosConfig.id}`); + } + + const aptosConfig = new AptosConfig({ + network: this.aptosConfig.network as Network, + fullnode: this.aptosConfig.rpcUrl, + }); + const aptos = new Aptos(aptosConfig); + + // Fetch sequence number for the account + const accountData = await aptos.getAccountInfo({ + accountAddress: this.walletState.account.accountAddress + }); + const sequenceNumber = BigInt(accountData.sequence_number); + + // Build and sign all transactions + const buildAndSignTransaction = async (txIndex: number, aptosSeqNo: bigint) => { + const transaction = await aptos.transaction.build.simple({ + sender: this.walletState.account!.accountAddress, + data: { + function: "0x1::aptos_account::transfer", + functionArguments: [this.walletState.account!.accountAddress, new U64(0)], // Transfer 0 APT to self + abi: { + signers: 1, + typeParameters: [], + parameters: [new TypeTagAddress(), new TypeTagU64()] + } + }, + options: { + accountSequenceNumber: aptosSeqNo + BigInt(txIndex), + gasUnitPrice: 100, // Default gas price + maxGasAmount: 1000, // Set a max gas + } + }); + + return { + transaction, + senderAuthenticator: aptos.transaction.sign({ + signer: this.walletState.account!, + transaction, + }) + }; + }; + + const signedTransactionPromises = []; + for (let txIndex = 0; txIndex < count; txIndex++) { + signedTransactionPromises.push(buildAndSignTransaction(txIndex, sequenceNumber)); + } + + const signedTransactions = await Promise.all(signedTransactionPromises); + + return signedTransactions.map((signedTx, index) => ({ + index, + data: { + signedTransaction: signedTx, + aptos, + } + })); + } + + async executeTransaction(tx: PreparedTransaction): Promise { + const startTime = Date.now(); + + try { + if (!tx.data.signedTransaction || !tx.data.aptos) { + throw new Error(`No pre-signed transaction available for Aptos tx #${tx.index}`); + } + + if (typeof tx.data.signedTransaction !== "object" || !("senderAuthenticator" in tx.data.signedTransaction)) { + throw new Error(`Invalid signed transaction for Aptos tx #${tx.index}`); + } + + const response = await tx.data.aptos.transaction.submit.simple(tx.data.signedTransaction); + + // Wait for transaction confirmation + await tx.data.aptos.waitForTransaction({ + transactionHash: response.hash, + options: { + waitForIndexer: false + } + }); + + const latency = Date.now() - startTime; + + const hash = response.hash.startsWith('0x') + ? response.hash as `0x${string}` + : `0x${response.hash}` as `0x${string}`; + + return { + hash, + latency, + success: true + }; + } catch (error) { + const latency = Date.now() - startTime; + return { + latency, + success: false, + error: this.formatAptosError(error) + }; + } + } + + // Aptos transactions are already confirmed when executed + async waitForConfirmation(txHash: string): Promise { + return { + hash: txHash, + confirmed: true + }; + } + + private formatAptosError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + + if (message.includes("insufficient funds")) { + return "Insufficient APT for transaction fees"; + } + if (message.includes("timeout")) { + return "Aptos network timeout - please try again"; + } + if (message.includes("SEQUENCE_NUMBER_TOO_OLD")) { + return "Transaction sequence error - please try again"; + } + + return message.split('\n')[0] || message; + } + + return String(error); + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/ChainAdapterFactory.ts b/packages/app/src/hooks/adapters/ChainAdapterFactory.ts new file mode 100644 index 0000000..aeb1307 --- /dev/null +++ b/packages/app/src/hooks/adapters/ChainAdapterFactory.ts @@ -0,0 +1,92 @@ +import type { AnyChainConfig } from "@/chain/networks"; +import type { SolanaChainConfig } from "@/solana/config"; +import type { FuelChainConfig } from "@/fuel/config"; +import type { AptosChainConfig } from "@/aptos/config"; +import type { SoonChainConfig } from "@/soon/config"; +import type { StarknetChainConfig } from "@/starknet/config"; +import type { Chain } from "viem"; + +import { ChainAdapter } from "./base/ChainAdapter.interface"; +import { EvmChainAdapter, type EvmWalletState } from "./EvmChainAdapter"; +import { SolanaChainAdapter, type SolanaWalletState } from "./SolanaChainAdapter"; +import { FuelChainAdapter, type FuelWalletState } from "./FuelChainAdapter"; +import { AptosChainAdapter, type AptosWalletState } from "./AptosChainAdapter"; +import { SoonChainAdapter, type SoonWalletState } from "./SoonChainAdapter"; +import { StarknetChainAdapter, type StarknetWalletState } from "./StarknetChainAdapter"; + +// Helper functions to distinguish chain types (copied from original useChainRace) +function isEvmChain(chain: AnyChainConfig): chain is Chain & { testnet: boolean; color: string; logo: string; faucetUrl?: string; layer: 'L1' | 'L2'; } { + return 'id' in chain && typeof chain.id === 'number'; +} + +function isSolanaChain(chain: AnyChainConfig): chain is SolanaChainConfig { + return 'cluster' in chain; +} + +function isFuelChain(chain: AnyChainConfig): chain is FuelChainConfig { + return chain.name === "Fuel Testnet" || chain.name === "Fuel Mainnet"; +} + +function isAptosChain(chain: AnyChainConfig): chain is AptosChainConfig { + return 'network' in chain && typeof chain.network === 'string' && + ('id' in chain && typeof chain.id === 'string' && chain.id.startsWith('aptos-')); +} + +function isSoonChain(chain: AnyChainConfig): chain is SoonChainConfig { + return 'id' in chain && typeof chain.id === 'string' && chain.id.startsWith('soon-'); +} + +function isStarknetChain(chain: AnyChainConfig): chain is StarknetChainConfig { + return chain.id === "starknet-testnet" || chain.id === "starknet-mainnet"; +} + +export interface WalletStates { + evm: EvmWalletState; + solana: SolanaWalletState; + fuel: FuelWalletState; + aptos: AptosWalletState; + soon: SoonWalletState; + starknet: StarknetWalletState; +} + +export class ChainAdapterFactory { + static create(chain: AnyChainConfig, walletStates: WalletStates): ChainAdapter { + if (isEvmChain(chain)) { + return new EvmChainAdapter({ + chainId: chain.id, + name: chain.name, + color: chain.color, + logo: chain.logo, + testnet: chain.testnet, + layer: chain.layer, + chain: chain as Chain + }, walletStates.evm); + } + + if (isSolanaChain(chain)) { + return new SolanaChainAdapter(chain, walletStates.solana); + } + + if (isFuelChain(chain)) { + return new FuelChainAdapter(chain, walletStates.fuel); + } + + if (isAptosChain(chain)) { + return new AptosChainAdapter(chain, walletStates.aptos); + } + + if (isSoonChain(chain)) { + return new SoonChainAdapter(chain, walletStates.soon); + } + + if (isStarknetChain(chain)) { + return new StarknetChainAdapter(chain, walletStates.starknet); + } + + throw new Error(`Unsupported chain type: ${(chain as any).name}`); + } + + static createMultiple(chains: AnyChainConfig[], walletStates: WalletStates): ChainAdapter[] { + return chains.map(chain => this.create(chain, walletStates)); + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/EvmChainAdapter.ts b/packages/app/src/hooks/adapters/EvmChainAdapter.ts new file mode 100644 index 0000000..18f998a --- /dev/null +++ b/packages/app/src/hooks/adapters/EvmChainAdapter.ts @@ -0,0 +1,297 @@ +import { + createPublicClient, + createWalletClient, + http, + type Hex, + type Chain, + type TransactionReceipt as ViemTransactionReceipt, + type Account +} from "viem"; +import { syncActions } from "shreds/viem"; +import { BaseChainAdapter } from "./base/BaseChainAdapter"; +import type { + ChainType, + ChainBalance, + PreparedTransaction, + TransactionResult, + TransactionReceipt +} from "./base/ChainAdapter.interface"; + +export interface EvmChainConfig { + chainId: number; + name: string; + color: string; + logo?: string; + testnet: boolean; + layer: 'L1' | 'L2'; + chain: Chain; // Viem chain object +} + +export interface EvmWalletState { + account: Account | null; + privateKey: string | null; + isReady: boolean; +} + +export class EvmChainAdapter extends BaseChainAdapter { + private chain: Chain; + private walletState: EvmWalletState; + + constructor(config: EvmChainConfig, walletState: EvmWalletState) { + super({ + chainId: config.chainId, + name: config.name, + color: config.color, + logo: config.logo, + testnet: config.testnet, + layer: config.layer + }); + + this.chain = config.chain; + this.walletState = walletState; + } + + get chainType(): ChainType { + return 'evm'; + } + + isWalletReady(): boolean { + return this.walletState.isReady && !!this.walletState.account; + } + + getWalletAddress(): string { + return this.walletState.account?.address || ''; + } + + async checkBalance(): Promise { + if (!this.walletState.account) { + return this.createErrorBalance("EVM wallet not ready"); + } + + return this.withTimeout( + this.withRetry(async () => { + const client = createPublicClient({ + chain: this.chain, + transport: http(), + }); + + const balance = await client.getBalance({ + address: this.walletState.account!.address + }); + + // Reduced balance threshold for testing (0.001 tokens) + const hasBalance = balance > BigInt(1e14); + + return { + chainId: this.chainId, + balance, + hasBalance, + }; + }) + ).catch(error => this.createErrorBalance(this.formatError(error))); + } + + async prepareTransactions(count: number): Promise { + if (!this.walletState.account) { + throw new Error("EVM wallet not ready"); + } + + const client = createPublicClient({ + chain: this.chain, + transport: http(), + }); + + const walletClient = createWalletClient({ + chain: this.chain, + transport: http(), + }); + + // Fetch required data in parallel + const [nonce, gasPrice] = await Promise.all([ + client.getTransactionCount({ + address: this.walletState.account.address, + }), + this.getGasPrice(client), + ]); + + // Pre-sign all transactions + const preparedTransactions: PreparedTransaction[] = []; + + for (let i = 0; i < count; i++) { + try { + const txParams = { + to: this.walletState.account.address, + value: BigInt(0), + gas: 21000n, + gasPrice, + nonce: nonce + i, + chainId: this.chain.id, + data: '0x' as const, + }; + + const signedTx = await walletClient.signTransaction({ + ...txParams, + account: this.walletState.account! + }); + + if (signedTx) { + preparedTransactions.push({ + index: i, + data: { + signedTransaction: signedTx, + hash: null, // Will be set during execution + } + }); + } + } catch (error) { + console.error(`Error preparing EVM tx #${i} for ${this.name}:`, error); + // Add placeholder to maintain index alignment + preparedTransactions.push({ + index: i, + data: { signedTransaction: null, hash: null } + }); + } + } + + return preparedTransactions; + } + + async executeTransaction(tx: PreparedTransaction): Promise { + const startTime = Date.now(); + + try { + if (!tx.data.signedTransaction) { + throw new Error(`No signed transaction for tx #${tx.index}`); + } + + let txHash: Hex; + + if (this.chain.id === 11155931) { + // RISE testnet with sync client - already includes confirmation + txHash = await this.executeRiseTransaction(tx.data.signedTransaction); + } else if (this.chain.id === 6342) { + // MegaETH with custom method - already includes confirmation + txHash = await this.executeMegaEthTransaction(tx.data.signedTransaction); + } else { + // Standard EVM chains - need to wait for confirmation + txHash = await this.executeStandardTransaction(tx.data.signedTransaction); + + // Wait for confirmation to get accurate timing + await this.waitForConfirmation(txHash); + } + + const latency = Date.now() - startTime; + + return { + hash: txHash, + latency, + success: true + }; + } catch (error) { + const latency = Date.now() - startTime; + return { + latency, + success: false, + error: this.formatError(error) + }; + } + } + + async waitForConfirmation(txHash: string): Promise { + // Skip confirmation for RISE and MegaETH as they're handled in executeTransaction + if (this.chain.id === 11155931 || this.chain.id === 6342) { + return { + hash: txHash, + confirmed: true + }; + } + + const client = createPublicClient({ + chain: this.chain, + transport: http(), + }); + + const receipt = await client.waitForTransactionReceipt({ + hash: txHash as Hex, + pollingInterval: 50, + retryDelay: 1, + timeout: 60_000, + }); + + return { + hash: txHash, + confirmed: true, + blockNumber: Number(receipt.blockNumber) + }; + } + + private async getGasPrice(client: any): Promise { + try { + const gasPrice = await client.getGasPrice(); + return gasPrice * BigInt(3); // Triple gas price for better confirmation + } catch { + // Fallback gas prices based on known chain requirements + const fallbackGasPrice = BigInt( + this.chain.id === 10143 ? 60000000000 : // Monad + this.chain.id === 8453 ? 2000000000 : // Base mainnet + this.chain.id === 17180 ? 1500000000 : // Sonic + this.chain.id === 6342 ? 3000000000 : // MegaETH + 1000000000 // Default (1 gwei) + ); + return fallbackGasPrice; + } + } + + private async executeRiseTransaction(signedTx: string): Promise { + const client = createPublicClient({ + chain: this.chain, + transport: http(), + }).extend(syncActions); + + const receipt = await client.sendRawTransactionSync({ + serializedTransaction: signedTx as Hex + }); + + if (!receipt?.transactionHash) { + throw new Error("RISE sync transaction failed"); + } + + return receipt.transactionHash; + } + + private async executeMegaEthTransaction(signedTx: string): Promise { + const client = createPublicClient({ + chain: this.chain, + transport: http(), + }); + + const receipt = await client.request({ + // @ts-expect-error - MegaETH custom method + method: 'realtime_sendRawTransaction', + params: [signedTx as Hex] + }) as ViemTransactionReceipt; + + if (!receipt?.transactionHash) { + throw new Error("MegaETH transaction failed"); + } + + return receipt.transactionHash; + } + + private async executeStandardTransaction(signedTx: string): Promise { + const client = createPublicClient({ + chain: this.chain, + transport: http(), + }); + + const txHash = await client.sendRawTransaction({ + serializedTransaction: signedTx as Hex + }); + + if (!txHash) { + throw new Error("Transaction failed to send"); + } + + return txHash; + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/FuelChainAdapter.ts b/packages/app/src/hooks/adapters/FuelChainAdapter.ts new file mode 100644 index 0000000..22ad50f --- /dev/null +++ b/packages/app/src/hooks/adapters/FuelChainAdapter.ts @@ -0,0 +1,263 @@ +import { + WalletUnlocked, + bn, + Provider, + type TransactionRequest, + ScriptTransactionRequest, + type Coin, + OutputChange +} from "fuels"; +import { BaseChainAdapter } from "./base/BaseChainAdapter"; +import type { FuelChainConfig } from "@/fuel/config"; +import type { + ChainType, + ChainBalance, + PreparedTransaction, + TransactionResult, + TransactionReceipt +} from "./base/ChainAdapter.interface"; + +export interface FuelWalletState { + wallet: WalletUnlocked | null; + isReady: boolean; +} + +export class FuelChainAdapter extends BaseChainAdapter { + private fuelConfig: FuelChainConfig; + private walletState: FuelWalletState; + + constructor(config: FuelChainConfig, walletState: FuelWalletState) { + super({ + chainId: config.id, + name: config.name, + color: config.color, + logo: config.logo, + testnet: config.testnet, + layer: config.layer + }); + + this.fuelConfig = config; + this.walletState = walletState; + } + + get chainType(): ChainType { + return 'fuel'; + } + + isWalletReady(): boolean { + return this.walletState.isReady && !!this.walletState.wallet; + } + + getWalletAddress(): string { + return this.walletState.wallet?.address?.toAddress() || ''; + } + + async checkBalance(): Promise { + if (!this.walletState.wallet || !this.walletState.isReady) { + return this.createErrorBalance("Fuel wallet still loading..."); + } + + return this.withTimeout( + this.withRetry(async () => { + const provider = new Provider(this.fuelConfig.rpcUrls.public.http[0]); + this.walletState.wallet!.connect(provider); + + const fuelBalance = await this.walletState.wallet!.getBalance(); + const balance = BigInt(fuelBalance.toString()); + + // Minimum balance threshold: 0.001 ETH (1e6 since Fuel uses 9 decimals) + const hasBalance = balance > BigInt(1e6); + + return { + chainId: this.chainId, + balance, + hasBalance, + }; + }) + ).catch(error => this.createErrorBalance(this.formatError(error))); + } + + async prepareTransactions(count: number): Promise { + if (!this.walletState.wallet) { + throw new Error(`Fuel wallet not ready for ${this.fuelConfig.id}`); + } + + const provider = new Provider(this.fuelConfig.rpcUrls.public.http[0]); + const wallet = this.walletState.wallet as WalletUnlocked; + wallet.connect(provider); + + const baseAssetId = await provider.getBaseAssetId(); + const walletCoins = await wallet.getCoins(baseAssetId); + + // Find UTXOs with sufficient balance (greater than 10000) + const coins = walletCoins.coins as Coin[]; + const validUtxos = coins.filter(coin => { + const amount = coin.amount.toNumber(); + return amount > 10000; + }); + + if (validUtxos.length === 0) { + throw new Error("No UTXOs with sufficient balance found"); + } + + const preparedTransactions: PreparedTransaction[] = []; + + // Only prepare the first transaction (others will be created dynamically) + try { + const initialScriptRequest = new ScriptTransactionRequest({ + script: "0x" + }); + initialScriptRequest.maxFee = bn(100); + initialScriptRequest.addCoinInput(validUtxos[0]); + + const initialSignedTx = await wallet.populateTransactionWitnessesSignature(initialScriptRequest); + + preparedTransactions.push({ + index: 0, + data: { + signedTransaction: initialSignedTx, + provider, + wallet, + baseAssetId, + lastResolvedOutput: null, // Will be updated during execution + } + }); + + // Add placeholders for other transactions + for (let i = 1; i < count; i++) { + preparedTransactions.push({ + index: i, + data: { + signedTransaction: null, + provider, + wallet, + baseAssetId, + lastResolvedOutput: null, + } + }); + } + } catch (error) { + console.error(`Error preparing first Fuel transaction:`, error); + throw error; + } + + return preparedTransactions; + } + + async executeTransaction(tx: PreparedTransaction): Promise { + const startTime = Date.now(); + + try { + let transaction; + + if (tx.index === 0) { + // First transaction - use pre-signed transaction + if (!tx.data.signedTransaction) { + throw new Error("No pre-signed transaction available"); + } + + transaction = await tx.data.provider.sendTransaction( + tx.data.signedTransaction as TransactionRequest, + { estimateTxDependencies: false } + ); + + const preConfOutput = await transaction.waitForPreConfirmation(); + if (preConfOutput.resolvedOutputs) { + const ethUTXO = preConfOutput.resolvedOutputs.find( + (output: any) => (output.output as OutputChange).assetId === tx.data.baseAssetId + ); + if (ethUTXO) { + tx.data.lastResolvedOutput = [ethUTXO]; + } + } + } else { + // Subsequent transactions using previous UTXO + if (!tx.data.lastResolvedOutput || tx.data.lastResolvedOutput.length === 0) { + throw new Error("No resolved output available for subsequent transaction"); + } + + const scriptRequest = new ScriptTransactionRequest({ + script: "0x" + }); + scriptRequest.maxFee = bn(100); + + const [{ utxoId, output }] = tx.data.lastResolvedOutput; + const change = output as unknown as { + assetId: string; + amount: string; + }; + + const resource = { + id: utxoId, + assetId: change.assetId, + amount: bn(change.amount), + owner: tx.data.wallet.address, + blockCreated: bn(0), + txCreatedIdx: bn(0), + }; + + scriptRequest.addResource(resource); + const signedTransaction = await tx.data.wallet.populateTransactionWitnessesSignature(scriptRequest); + + transaction = await tx.data.provider.sendTransaction( + signedTransaction as TransactionRequest, + { estimateTxDependencies: false } + ); + + const preConfOutput = await transaction.waitForPreConfirmation(); + if (preConfOutput.resolvedOutputs) { + const ethUTXO = preConfOutput.resolvedOutputs.find( + (output: any) => (output.output as OutputChange).assetId === tx.data.baseAssetId + ); + if (ethUTXO) { + tx.data.lastResolvedOutput = [ethUTXO]; + } + } + } + + if (!transaction) { + throw new Error("Failed to send transaction"); + } + + const latency = Date.now() - startTime; + + return { + hash: `0x${transaction.id}` as `0x${string}`, + latency, + success: true + }; + } catch (error) { + const latency = Date.now() - startTime; + return { + latency, + success: false, + error: this.formatFuelError(error) + }; + } + } + + // Fuel transactions are already confirmed when executed + async waitForConfirmation(txHash: string): Promise { + return { + hash: txHash, + confirmed: true + }; + } + + private formatFuelError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + + if (message.includes("insufficient funds")) { + return "Insufficient ETH for transaction fees"; + } + if (message.includes("timeout")) { + return "Fuel network timeout - please try again"; + } + + return message.split('\n')[0] || message; + } + + return String(error); + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/SolanaChainAdapter.ts b/packages/app/src/hooks/adapters/SolanaChainAdapter.ts new file mode 100644 index 0000000..eb49db9 --- /dev/null +++ b/packages/app/src/hooks/adapters/SolanaChainAdapter.ts @@ -0,0 +1,288 @@ +import { Connection, SystemProgram, Transaction, sendAndConfirmTransaction, Keypair, PublicKey } from "@solana/web3.js"; +import { BaseChainAdapter } from "./base/BaseChainAdapter"; +import type { SolanaChainConfig } from "@/solana/config"; +import type { + ChainType, + ChainBalance, + PreparedTransaction, + TransactionResult, + TransactionReceipt +} from "./base/ChainAdapter.interface"; + +export interface SolanaWalletState { + publicKey: PublicKey | null; + keypair: Keypair | null; + isReady: boolean; +} + +export class SolanaChainAdapter extends BaseChainAdapter { + private solanaConfig: SolanaChainConfig; + private walletState: SolanaWalletState; + private fallbackEndpoints: string[]; + + constructor(config: SolanaChainConfig, walletState: SolanaWalletState) { + super({ + chainId: config.id, + name: config.name, + color: config.color, + logo: config.logo, + testnet: config.id.includes('mainnet') ? false : true, // Derive testnet from id + }); + + this.solanaConfig = config; + this.walletState = walletState; + this.fallbackEndpoints = this.getFallbackEndpoints(); + } + + get chainType(): ChainType { + return 'solana'; + } + + isWalletReady(): boolean { + return this.walletState.isReady && !!this.walletState.publicKey && !!this.walletState.keypair; + } + + getWalletAddress(): string { + return this.walletState.publicKey?.toBase58() || ''; + } + + async checkBalance(): Promise { + if (!this.walletState.publicKey || !this.walletState.isReady) { + return this.createErrorBalance("Solana wallet still loading..."); + } + + return this.withTimeout( + this.withRetry(async () => { + // Try each endpoint until one works + for (const endpoint of this.fallbackEndpoints) { + try { + const connection = new Connection(endpoint, this.solanaConfig.commitment); + const lamports = await connection.getBalance( + this.walletState.publicKey!, + this.solanaConfig.commitment + ); + + const balance = BigInt(lamports); + // Minimum balance threshold: 0.001 SOL (1,000,000 lamports) + const hasBalance = balance > BigInt(1_000_000); + + return { + chainId: this.chainId, + balance, + hasBalance, + }; + } catch (error) { + console.warn(`Solana RPC ${endpoint} failed for ${this.solanaConfig.id}:`, error); + continue; + } + } + + throw new Error(`All Solana RPC endpoints failed for ${this.solanaConfig.id}`); + }) + ).catch(error => this.createErrorBalance(this.formatError(error))); + } + + async prepareTransactions(count: number): Promise { + if (!this.walletState.keypair) { + throw new Error(`Solana wallet not ready for ${this.solanaConfig.id}`); + } + + // Find working connection + const connection = await this.getWorkingConnection(); + if (!connection) { + throw new Error(`All Solana RPC endpoints failed for ${this.solanaConfig.id} during setup`); + } + + const preparedTransactions: PreparedTransaction[] = []; + + try { + // Get the latest blockhash for all transactions + const { blockhash } = await connection.getLatestBlockhash(this.solanaConfig.commitment); + + for (let i = 0; i < count; i++) { + try { + // Create transaction with unique transfer amount to avoid duplicate signatures + const transaction = new Transaction(); + transaction.feePayer = this.walletState.keypair.publicKey; + transaction.recentBlockhash = blockhash; + transaction.add( + SystemProgram.transfer({ + fromPubkey: this.walletState.keypair.publicKey, + toPubkey: this.walletState.keypair.publicKey, + lamports: i + 1, // Use different amounts (1, 2, 3, etc.) + }) + ); + + // Sign the transaction + transaction.sign(this.walletState.keypair); + + // Serialize the signed transaction + const serializedTx = transaction.serialize(); + + preparedTransactions.push({ + index: i, + data: { + serializedTransaction: serializedTx, + connection, + signature: null, // Will be set during execution + } + }); + } catch (error) { + console.error(`Error signing Solana tx #${i} for ${this.solanaConfig.id}:`, error); + preparedTransactions.push({ + index: i, + data: { serializedTransaction: null, connection, signature: null } + }); + } + } + } catch (error) { + console.error(`Error getting blockhash for Solana ${this.solanaConfig.id}:`, error); + throw error; + } + + return preparedTransactions; + } + + async executeTransaction(tx: PreparedTransaction): Promise { + const startTime = Date.now(); + + try { + if (!tx.data.serializedTransaction || !tx.data.connection) { + throw new Error(`No prepared transaction data for Solana tx #${tx.index}`); + } + + // Send the pre-signed transaction + const signature = await tx.data.connection.sendRawTransaction( + tx.data.serializedTransaction, + { + skipPreflight: false, + preflightCommitment: this.solanaConfig.commitment, + } + ); + + // Wait for confirmation + await tx.data.connection.confirmTransaction( + signature, + this.solanaConfig.commitment + ); + + const latency = Date.now() - startTime; + + return { + signature, + latency, + success: true + }; + } catch (error) { + // Fallback: create fresh transaction if pre-signed failed + if (!this.walletState.keypair || !tx.data.connection) { + const latency = Date.now() - startTime; + return { + latency, + success: false, + error: this.formatError(error) + }; + } + + try { + console.warn(`Pre-signed Solana tx #${tx.index} failed, creating fresh transaction`); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: this.walletState.keypair.publicKey, + toPubkey: this.walletState.keypair.publicKey, + lamports: tx.index + 1, + }) + ); + + const signature = await sendAndConfirmTransaction( + tx.data.connection, + transaction, + [this.walletState.keypair], + { + commitment: this.solanaConfig.commitment, + preflightCommitment: this.solanaConfig.commitment, + } + ); + + const latency = Date.now() - startTime; + + return { + signature, + latency, + success: true + }; + } catch (fallbackError) { + const latency = Date.now() - startTime; + return { + latency, + success: false, + error: this.formatSolanaError(fallbackError) + }; + } + } + } + + // Solana doesn't need separate confirmation step + async waitForConfirmation(signature: string): Promise { + return { + hash: signature, + confirmed: true + }; + } + + private getFallbackEndpoints(): string[] { + const endpoints = [this.solanaConfig.endpoint]; + + // Add fallback endpoints based on chain ID + if (this.solanaConfig.id === 'solana-mainnet') { + endpoints.push( + 'https://api.mainnet-beta.solana.com', + 'https://solana-api.projectserum.com', + 'https://rpc.ankr.com/solana' + ); + } else if (this.solanaConfig.id === 'solana-devnet') { + endpoints.push('https://api.devnet.solana.com'); + } else { + endpoints.push('https://api.testnet.solana.com'); + } + + return endpoints; + } + + private async getWorkingConnection(): Promise { + for (const endpoint of this.fallbackEndpoints) { + try { + const connection = new Connection(endpoint, this.solanaConfig.commitment); + // Test the connection by getting latest blockhash + await connection.getLatestBlockhash(this.solanaConfig.commitment); + console.log(`Using Solana RPC ${endpoint} for ${this.solanaConfig.id}`); + return connection; + } catch (error) { + console.warn(`Solana RPC ${endpoint} failed for ${this.solanaConfig.id} during setup:`, error); + continue; + } + } + return null; + } + + private formatSolanaError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + + if (message.includes("insufficient funds")) { + return "Insufficient SOL for transaction fees"; + } + if (message.includes("blockhash not found")) { + return "Transaction expired - please try again"; + } + if (message.includes("timeout")) { + return "Solana network timeout - please try again"; + } + + return message.split('\n')[0] || message; + } + + return String(error); + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/SoonChainAdapter.ts b/packages/app/src/hooks/adapters/SoonChainAdapter.ts new file mode 100644 index 0000000..dab556b --- /dev/null +++ b/packages/app/src/hooks/adapters/SoonChainAdapter.ts @@ -0,0 +1,200 @@ +import { Connection, SystemProgram, Transaction, Keypair, PublicKey } from "@solana/web3.js"; +import { BaseChainAdapter } from "./base/BaseChainAdapter"; +import type { SoonChainConfig } from "@/soon/config"; +import type { + ChainType, + ChainBalance, + PreparedTransaction, + TransactionResult, + TransactionReceipt +} from "./base/ChainAdapter.interface"; + +export interface SoonWalletState { + publicKey: PublicKey | null; + keypair: Keypair | null; +} + +export class SoonChainAdapter extends BaseChainAdapter { + private soonConfig: SoonChainConfig; + private walletState: SoonWalletState; + + constructor(config: SoonChainConfig, walletState: SoonWalletState) { + super({ + chainId: config.id, + name: config.name, + color: config.color, + logo: config.logo, + testnet: config.id.includes('mainnet') ? false : true, // Derive testnet from id + }); + + this.soonConfig = config; + this.walletState = walletState; + } + + get chainType(): ChainType { + return 'soon'; + } + + isWalletReady(): boolean { + return !!this.walletState.publicKey && !!this.walletState.keypair; + } + + getWalletAddress(): string { + return this.walletState.publicKey?.toBase58() || ''; + } + + async checkBalance(): Promise { + if (!this.walletState.publicKey) { + return this.createErrorBalance("SOON wallet still loading..."); + } + + return this.withTimeout( + this.withRetry(async () => { + const connection = new Connection(this.soonConfig.endpoint, this.soonConfig.commitment); + const lamports = await connection.getBalance( + this.walletState.publicKey!, + this.soonConfig.commitment + ); + + const balance = BigInt(lamports); + // Minimum balance threshold: 0.001 ETH (1,000,000 lamports for SOON) + const hasBalance = balance > BigInt(1_000_000); + + return { + chainId: this.chainId, + balance, + hasBalance, + }; + }) + ).catch(error => this.createErrorBalance(this.formatError(error))); + } + + async prepareTransactions(count: number): Promise { + if (!this.walletState.keypair) { + throw new Error("SOON wallet not initialized"); + } + + const connection = new Connection(this.soonConfig.endpoint, this.soonConfig.commitment); + + // Test the connection + await connection.getLatestBlockhash(this.soonConfig.commitment); + console.log(`Using SOON RPC ${this.soonConfig.endpoint} for ${this.soonConfig.id}`); + + const preparedTransactions: PreparedTransaction[] = []; + + try { + // Get the latest blockhash for all transactions + const { blockhash } = await connection.getLatestBlockhash(this.soonConfig.commitment); + + for (let i = 0; i < count; i++) { + try { + // Create transaction with unique transfer amount to avoid duplicate signatures + const transaction = new Transaction(); + transaction.feePayer = this.walletState.keypair.publicKey; + transaction.recentBlockhash = blockhash; + transaction.add( + SystemProgram.transfer({ + fromPubkey: this.walletState.keypair.publicKey, + toPubkey: this.walletState.keypair.publicKey, + lamports: i + 1, // Use different amounts to make transactions unique + }) + ); + + // Sign the transaction + transaction.sign(this.walletState.keypair); + + // Serialize the signed transaction + const serializedTx = transaction.serialize(); + + preparedTransactions.push({ + index: i, + data: { + serializedTransaction: serializedTx, + connection, + signature: null, // Will be set during execution + } + }); + } catch (error) { + console.error(`Error signing SOON tx #${i} for ${this.soonConfig.id}:`, error); + preparedTransactions.push({ + index: i, + data: { serializedTransaction: null, connection, signature: null } + }); + } + } + } catch (error) { + console.error(`Error getting blockhash for SOON ${this.soonConfig.id}:`, error); + throw error; + } + + return preparedTransactions; + } + + async executeTransaction(tx: PreparedTransaction): Promise { + const startTime = Date.now(); + + try { + if (!tx.data.serializedTransaction || !tx.data.connection) { + throw new Error(`No prepared transaction data for SOON tx #${tx.index}`); + } + + // Send the pre-signed transaction + const signature = await tx.data.connection.sendRawTransaction( + tx.data.serializedTransaction, + { + skipPreflight: false, + preflightCommitment: this.soonConfig.commitment, + } + ); + + // Wait for confirmation + await tx.data.connection.confirmTransaction( + signature, + this.soonConfig.commitment + ); + + const latency = Date.now() - startTime; + + return { + signature, + latency, + success: true + }; + } catch (error) { + const latency = Date.now() - startTime; + return { + latency, + success: false, + error: this.formatSoonError(error) + }; + } + } + + // SOON doesn't need separate confirmation step (SVM-based) + async waitForConfirmation(signature: string): Promise { + return { + hash: signature, + confirmed: true + }; + } + + private formatSoonError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + + if (message.includes("insufficient funds")) { + return "Insufficient funds for SOON transaction fees"; + } + if (message.includes("blockhash not found")) { + return "Transaction expired - please try again"; + } + if (message.includes("timeout")) { + return "SOON network timeout - please try again"; + } + + return message.split('\n')[0] || message; + } + + return String(error); + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/StarknetChainAdapter.ts b/packages/app/src/hooks/adapters/StarknetChainAdapter.ts new file mode 100644 index 0000000..f06c626 --- /dev/null +++ b/packages/app/src/hooks/adapters/StarknetChainAdapter.ts @@ -0,0 +1,185 @@ +import { + cairo, + Call, + Contract, + RpcProvider, + Account +} from "starknet"; +import { Erc20Abi } from "../../util/erc20abi"; +import { STRK_ADDRESS } from "../../util/erc20Contract"; +import { BaseChainAdapter } from "./base/BaseChainAdapter"; +import type { StarknetChainConfig } from "@/starknet/config"; +import type { + ChainType, + ChainBalance, + PreparedTransaction, + TransactionResult, + TransactionReceipt +} from "./base/ChainAdapter.interface"; + +export interface StarknetWalletState { + starknetprivateKey: string | null; + starknetaccount: Account | null; + starknetisReady: boolean; +} + +export class StarknetChainAdapter extends BaseChainAdapter { + private starknetConfig: StarknetChainConfig; + private walletState: StarknetWalletState; + + constructor(config: StarknetChainConfig, walletState: StarknetWalletState) { + super({ + chainId: config.id, + name: config.name, + color: config.color, + logo: config.logo, + testnet: config.id.includes('mainnet') ? false : true, // Derive testnet from id + }); + + this.starknetConfig = config; + this.walletState = walletState; + } + + get chainType(): ChainType { + return 'starknet'; + } + + isWalletReady(): boolean { + return this.walletState.starknetisReady && !!this.walletState.starknetaccount && !!this.walletState.starknetprivateKey; + } + + getWalletAddress(): string { + return this.walletState.starknetaccount?.address || ''; + } + + async checkBalance(): Promise { + if (!this.walletState.starknetaccount?.address) { + return this.createErrorBalance("No Starknet account address available"); + } + + return this.withTimeout( + this.withRetry(async () => { + const provider = new RpcProvider({ nodeUrl: this.starknetConfig.endpoint }); + const erc20Contract = new Contract(Erc20Abi, STRK_ADDRESS, provider); + + // Get the balance using the correct method + const starknetBalance = await erc20Contract.balance_of(this.walletState.starknetaccount!.address); + const balance = BigInt(starknetBalance.toString()); + + // Minimum balance threshold: 0.02 STRK (20000000000000000 since STRK uses 18 decimals) + const hasBalance = balance > BigInt("20000000000000000"); + + return { + chainId: this.chainId, + balance, + hasBalance, + }; + }) + ).catch(error => this.createErrorBalance(this.formatError(error))); + } + + async prepareTransactions(count: number): Promise { + if (!this.walletState.starknetaccount || !this.walletState.starknetprivateKey) { + throw new Error(`Starknet wallet not ready for ${this.starknetConfig.id}`); + } + + const provider = new RpcProvider({ nodeUrl: this.starknetConfig.endpoint }); + const account = new Account( + provider, + this.walletState.starknetaccount.address, + this.walletState.starknetprivateKey + ); + + // For Starknet, we'll prepare the transfer calls but execute them individually + // since each transaction needs a fresh nonce + const preparedTransactions: PreparedTransaction[] = []; + + for (let i = 0; i < count; i++) { + preparedTransactions.push({ + index: i, + data: { + provider, + account, + transferAmount: (i + 1) * 10 ** 18, // Different amounts for each tx + } + }); + } + + return preparedTransactions; + } + + async executeTransaction(tx: PreparedTransaction): Promise { + const startTime = Date.now(); + + try { + if (!tx.data.provider || !tx.data.account) { + throw new Error(`No prepared transaction data for Starknet tx #${tx.index}`); + } + + // Get current nonce for this transaction + const currentNonce = await tx.data.account.getNonce(); + + const erc20Contract = new Contract(Erc20Abi, STRK_ADDRESS, tx.data.account); + const amount = cairo.uint256(tx.data.transferAmount); + + const transferCall: Call = erc20Contract.populate("transfer", { + recipient: this.walletState.starknetaccount!.address, + amount: amount, + }); + + const { transaction_hash: transferTxHash } = await tx.data.account.execute( + transferCall, + { + nonce: currentNonce, + version: 3, + } + ); + + // Wait for transaction confirmation + await tx.data.provider.waitForTransaction(transferTxHash); + + const latency = Date.now() - startTime; + + return { + hash: transferTxHash as `0x${string}`, + latency, + success: true + }; + } catch (error) { + const latency = Date.now() - startTime; + return { + latency, + success: false, + error: this.formatStarknetError(error) + }; + } + } + + // Starknet transactions are already confirmed when executed + async waitForConfirmation(txHash: string): Promise { + return { + hash: txHash, + confirmed: true + }; + } + + private formatStarknetError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + + if (message.includes("insufficient funds")) { + return "Insufficient STRK for transaction fees"; + } + if (message.includes("nonce")) { + return "Transaction nonce issue - please try again"; + } + if (message.includes("timeout")) { + return "Starknet network timeout - please try again"; + } + + return message.split('\n')[0] || message; + } + + return String(error); + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/base/BaseChainAdapter.ts b/packages/app/src/hooks/adapters/base/BaseChainAdapter.ts new file mode 100644 index 0000000..5831747 --- /dev/null +++ b/packages/app/src/hooks/adapters/base/BaseChainAdapter.ts @@ -0,0 +1,71 @@ +import { ChainAdapter, type ChainAdapterConfig, type ChainBalance } from "./ChainAdapter.interface"; + +export abstract class BaseChainAdapter extends ChainAdapter { + protected retryCount = 3; + protected timeoutMs = 30000; + + constructor(config: ChainAdapterConfig) { + super(config); + } + + protected async withRetry( + operation: () => Promise, + retries = this.retryCount + ): Promise { + try { + return await operation(); + } catch (error) { + if (retries <= 0) { + throw error; + } + + console.warn(`Operation failed, retrying... (${retries} attempts left)`); + + // Exponential backoff + const backoffTime = 1000 * Math.pow(2, this.retryCount - retries); + await new Promise(resolve => setTimeout(resolve, backoffTime)); + + return this.withRetry(operation, retries - 1); + } + } + + protected async withTimeout( + operation: Promise, + timeoutMs = this.timeoutMs + ): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + return Promise.race([operation, timeoutPromise]); + } + + protected createErrorBalance(error: string): ChainBalance { + return { + chainId: this.chainId, + balance: BigInt(0), + hasBalance: false, + error + }; + } + + protected formatError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + + if (message.includes("insufficient funds")) { + return "Insufficient funds for transaction fees"; + } + if (message.includes("timeout")) { + return "Network timeout - chain may be congested"; + } + if (message.includes("nonce")) { + return "Transaction nonce issue - try again"; + } + + return message.split('\n')[0] || message; + } + + return String(error); + } +} \ No newline at end of file diff --git a/packages/app/src/hooks/adapters/base/ChainAdapter.interface.ts b/packages/app/src/hooks/adapters/base/ChainAdapter.interface.ts new file mode 100644 index 0000000..592b2d7 --- /dev/null +++ b/packages/app/src/hooks/adapters/base/ChainAdapter.interface.ts @@ -0,0 +1,63 @@ +import type { Hex } from "viem"; + +export type ChainType = 'evm' | 'solana' | 'fuel' | 'aptos' | 'soon' | 'starknet'; + +export interface ChainBalance { + chainId: number | string; + balance: bigint; + hasBalance: boolean; + error?: string; +} + +export interface PreparedTransaction { + index: number; + data: any; // Chain-specific transaction data +} + +export interface TransactionResult { + hash?: Hex; + signature?: string; + latency: number; + success: boolean; + error?: string; +} + +export interface TransactionReceipt { + hash: string; + confirmed: boolean; + blockNumber?: number; +} + +export interface ChainAdapterConfig { + chainId: number | string; + name: string; + color: string; + logo?: string; + testnet: boolean; + layer?: 'L1' | 'L2'; +} + +export abstract class ChainAdapter { + protected config: ChainAdapterConfig; + + constructor(config: ChainAdapterConfig) { + this.config = config; + } + + // Basic chain info + get chainId() { return this.config.chainId; } + get name() { return this.config.name; } + get color() { return this.config.color; } + get logo() { return this.config.logo; } + get testnet() { return this.config.testnet; } + get layer() { return this.config.layer; } + + // Abstract methods that each chain type must implement + abstract get chainType(): ChainType; + abstract isWalletReady(): boolean; + abstract getWalletAddress(): string; + abstract checkBalance(): Promise; + abstract prepareTransactions(count: number): Promise; + abstract executeTransaction(tx: PreparedTransaction): Promise; + abstract waitForConfirmation?(txHash: string): Promise; +} \ No newline at end of file diff --git a/packages/app/src/hooks/useChainRace.original.ts b/packages/app/src/hooks/useChainRace.original.ts new file mode 100644 index 0000000..66296c1 --- /dev/null +++ b/packages/app/src/hooks/useChainRace.original.ts @@ -0,0 +1,2273 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { createPublicClient, createWalletClient, http, type Hex, type Chain, TransactionReceipt } from "viem"; +import { allChains, type AnyChainConfig } from "@/chain/networks"; +import { useEmbeddedWallet } from "./useEmbeddedWallet"; +import { useSolanaEmbeddedWallet } from "./useSolanaEmbeddedWallet"; +import { useFuelEmbeddedWallet } from "./useFuelEmbeddedWallet"; +import { useAptosEmbeddedWallet } from "./useAptosEmbeddedWallet"; +import { useSoonEmbeddedWallet } from "./useSoonEmbeddedWallet"; +import { syncActions } from "shreds/viem"; +import { Connection, SystemProgram, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; +import type { SolanaChainConfig } from "@/solana/config"; +import type { FuelChainConfig } from "@/fuel/config"; +import type { AptosChainConfig } from "@/aptos/config"; +import type { SoonChainConfig } from "@/soon/config"; +import { + Aptos, + AptosConfig, + Network, + type SimpleTransaction, + type AccountAuthenticator, + TypeTagAddress, TypeTagU64, U64, +} from "@aptos-labs/ts-sdk"; +import { getGeo } from "@/lib/geo"; +import { saveRaceResults } from "@/lib/api"; +import { WalletUnlocked, bn, Provider, type TransactionRequest, ScriptTransactionRequest, type Coin, ResolvedOutput, OutputChange } from "fuels"; +import { useStarknetEmbeddedWallet } from "./useStarknetEmbeddedWallet"; +import { StarknetChainConfig } from "@/starknet/config"; +import { Erc20Abi } from "../util/erc20abi"; +import { STRK_ADDRESS } from "../util/erc20Contract"; +import { + cairo, + Call, + Contract, + RpcProvider, +} from "starknet"; +import { Account } from "starknet"; + + +export type ChainRaceStatus = "idle" | "funding" | "ready" | "racing" | "finished"; + +export interface ChainBalance { + chainId: number | string; // Support both EVM (number) and Solana (string) chain IDs + balance: bigint; + hasBalance: boolean; + error?: string; +} + +export interface RaceResult { + chainId: number | string; // Support both EVM (number) and Solana (string) chain IDs + name: string; + color: string; + logo?: string; // Path to the chain logo + status: "pending" | "racing" | "success" | "error"; + txHash?: Hex; // EVM transaction hash + signature?: string; // Solana transaction signature + error?: string; + position?: number; + txCompleted: number; // Count of completed transactions + txTotal: number; // Total transactions required + txLatencies: number[]; // Array of individual transaction latencies in ms + averageLatency?: number; // Average transaction latency + totalLatency?: number; // Total latency of all transactions combined +} + +export type TransactionCount = 1 | 5 | 10 | 20; + +export type LayerFilter = 'L1' | 'L2' | 'Both'; + +export type NetworkFilter = 'Mainnet' | 'Testnet'; + +export interface RaceSessionPayload { + title: string; + walletAddress: string; + transactionCount: number; + status: 'completed'; + city?: string; + region?: string; + country?: string; + results: ChainResultPayload[]; +} + +export interface ChainResultPayload { + chainId: number; + chainName: string; + txLatencies: number[]; // raw per-tx times + averageLatency: number; + totalLatency: number; + status: string; + position?: number; +} + +// Constants for localStorage keys +const LOCAL_STORAGE_SELECTED_CHAINS = "horse-race-selected-chains"; +const LOCAL_STORAGE_TX_COUNT = "horse-race-tx-count"; +const LOCAL_STORAGE_LAYER_FILTER = "horse-race-layer-filter"; +const LOCAL_STORAGE_NETWORK_FILTER = "horse-race-network-filter"; + +// Helper functions to distinguish chain types +function isEvmChain(chain: AnyChainConfig): chain is Chain & { testnet: boolean; color: string; logo: string; faucetUrl?: string; layer: 'L1' | 'L2'; } { + return 'id' in chain && typeof chain.id === 'number'; +} + +function isSolanaChain(chain: AnyChainConfig): chain is SolanaChainConfig { + return 'cluster' in chain; +} + +function isFuelChain(chain: AnyChainConfig): chain is FuelChainConfig { + return chain.name === "Fuel Testnet" || chain.name === "Fuel Mainnet"; +} + +function isAptosChain(chain: AnyChainConfig): chain is AptosChainConfig { + return 'network' in chain && typeof chain.network === 'string' && + ('id' in chain && typeof chain.id === 'string' && chain.id.startsWith('aptos-')); +} + +function isSoonChain(chain: AnyChainConfig): chain is SoonChainConfig { + return 'id' in chain && typeof chain.id === 'string' && chain.id.startsWith('soon-'); +} + +function isStarknetChain(chain: AnyChainConfig): chain is StarknetChainConfig { + return chain.id === "starknet-testnet" || chain.id === "starknet-mainnet"; +} + +// Helper function to get fallback RPC endpoints for Solana +function getSolanaFallbackEndpoints(chain: SolanaChainConfig): string[] { + const fallbackEndpoints = [ + chain.endpoint, + // Fallback RPC endpoints for Solana + ...(chain.id === 'solana-mainnet' ? [ + 'https://api.mainnet-beta.solana.com', + 'https://solana-api.projectserum.com', + 'https://rpc.ankr.com/solana', + ] : chain.id === 'solana-devnet' ? [ + 'https://api.devnet.solana.com', + ] : [ + 'https://api.testnet.solana.com', + ]) + ]; + return fallbackEndpoints; +} + +export function useChainRace() { + const { account, privateKey, isReady, resetWallet } = useEmbeddedWallet(); + const { publicKey: solanaPublicKey, keypair: solanaKeypair, isReady: solanaReady } = useSolanaEmbeddedWallet(); + const { wallet: fuelWallet, isReady: fuelReady } = useFuelEmbeddedWallet(); + const { account: aptosAccount, address: aptosAddress, isReady: aptosReady } = useAptosEmbeddedWallet(); + const { publicKey: soonPublicKey, keypair: soonKeypair } = useSoonEmbeddedWallet(); + const { starknetprivateKey, starknetaccount, starknetisReady } = useStarknetEmbeddedWallet(); + const [status, setStatus] = useState("idle"); + const [balances, setBalances] = useState([]); + const [results, setResults] = useState([]); + const [isLoadingBalances, setIsLoadingBalances] = useState(false); + + const [transactionCount, setTransactionCount] = useState(() => { + // Load saved transaction count from localStorage if available + // if (typeof window !== 'undefined') { + // const savedCount = localStorage.getItem(LOCAL_STORAGE_TX_COUNT); + // if (savedCount) { + // const count = parseInt(savedCount, 10) as TransactionCount; + // if ([1, 5, 10, 20].includes(count)) { + // return count; + // } + // } + // } + return 10; + }); + + const [layerFilter, setLayerFilter] = useState(() => { + // Load saved layer filter from localStorage if available + if (typeof window !== 'undefined') { + const savedFilter = localStorage.getItem(LOCAL_STORAGE_LAYER_FILTER); + if (savedFilter && ['L1', 'L2', 'Both'].includes(savedFilter)) { + return savedFilter as LayerFilter; + } + } + return 'Both'; + }); + + const [networkFilter, setNetworkFilter] = useState(() => { + // Load saved network filter from localStorage if available + if (typeof window !== 'undefined') { + const savedFilter = localStorage.getItem(LOCAL_STORAGE_NETWORK_FILTER); + if (savedFilter && ['Mainnet', 'Testnet'].includes(savedFilter)) { + return savedFilter as NetworkFilter; + } + } + return 'Testnet'; // Default to testnet for safety + }); + + const [selectedChains, setSelectedChains] = useState<(number | string)[]>(() => { + // Load saved chain selection from localStorage if available + if (typeof window !== 'undefined') { + const savedChains = localStorage.getItem(LOCAL_STORAGE_SELECTED_CHAINS); + if (savedChains) { + try { + const parsed = JSON.parse(savedChains) as (number | string)[]; + // Validate that all chains in the saved list are actually valid chains + const validChainIds: (number | string)[] = allChains.map(chain => isEvmChain(chain) ? chain.id : chain.id); + const validSavedChains = parsed.filter(id => validChainIds.includes(id)); + + if (validSavedChains.length > 0) { + return validSavedChains; + } + } catch (e) { + console.error('Failed to parse saved chain selection:', e); + } + } + } + // Default to all chains (EVM + Solana) + return allChains.map(chain => isEvmChain(chain) ? chain.id : chain.id); + }); + + // Effect to save chain selection to localStorage when it changes + useEffect(() => { + if (typeof window !== 'undefined' && selectedChains.length > 0) { + localStorage.setItem(LOCAL_STORAGE_SELECTED_CHAINS, JSON.stringify(selectedChains)); + } + }, [selectedChains]); + + // Effect to save transaction count to localStorage when it changes + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem(LOCAL_STORAGE_TX_COUNT, transactionCount.toString()); + } + }, [transactionCount]); + + // Effect to save layer filter to localStorage when it changes + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem(LOCAL_STORAGE_LAYER_FILTER, layerFilter); + } + }, [layerFilter]); + + // Effect to save network filter to localStorage when it changes + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem(LOCAL_STORAGE_NETWORK_FILTER, networkFilter); + } + }, [networkFilter]); + + // Get filtered chains based on layer filter and network filter + const getFilteredChains = useCallback(() => { + return allChains.filter(chain => { + // Layer filter + if (layerFilter !== 'Both') { + if (isEvmChain(chain)) { + if (chain.layer !== layerFilter) return false; + } else if (isFuelChain(chain)) { + if (chain.layer !== layerFilter) return false; + } else if (isAptosChain(chain)) { + if (chain.layer !== layerFilter) return false; + } else if (isSoonChain(chain)) { + // For SOON chains, we'll consider them as L2 for filtering purposes (SVM rollup) + if (layerFilter !== 'L2') return false; + } else if (isStarknetChain(chain)) { + if (layerFilter !== 'L2') return false; + } else { + // For Solana chains, we'll consider them as L1 for filtering purposes + if (layerFilter !== 'L1') return false; + } + } + + // Network filter (mainnet vs testnet) - no "Both" option + if (isEvmChain(chain)) { + const isTestnet = chain.testnet; + if (networkFilter === 'Testnet' && !isTestnet) return false; + if (networkFilter === 'Mainnet' && isTestnet) return false; + } else if (isFuelChain(chain)) { + const isTestnet = chain.testnet; + if (networkFilter === 'Testnet' && !isTestnet) return false; + if (networkFilter === 'Mainnet' && isTestnet) return false; + } else if (isAptosChain(chain)) { + const isTestnet = chain.testnet; + if (networkFilter === 'Testnet' && !isTestnet) return false; + if (networkFilter === 'Mainnet' && isTestnet) return false; + } else if (isSoonChain(chain)) { + const isTestnet = chain.testnet; + if (networkFilter === 'Testnet' && !isTestnet) return false; + if (networkFilter === 'Mainnet' && isTestnet) return false; + }else if (isStarknetChain(chain)) { + const isTestnet = chain.testnet; + if (networkFilter === 'Testnet' && !isTestnet) return false; + if (networkFilter === 'Mainnet' && isTestnet) return false; + } + else { + // For Solana chains, check if it's mainnet or testnet based on the id + const isMainnet = chain.id === 'solana-mainnet'; + if (networkFilter === 'Mainnet' && !isMainnet) return false; + if (networkFilter === 'Testnet' && isMainnet) return false; + } + + return true; + }); + }, [layerFilter, networkFilter]); + + // Define checkBalances before using it in useEffect + const checkBalances = useCallback(async () => { + // Only require EVM wallet to be ready - other wallets can load independently + if (!account) { + setIsLoadingBalances(false); + return; + } + + setIsLoadingBalances(true); + + try { + // Check balances for all chains regardless of selection + const activeChains = allChains; + + const balancePromises = activeChains.map(async (chain) => { + // Function to attempt a balance check with retries + const attemptBalanceCheck = async (retryCount = 0, maxRetries = 3): Promise<{ + chainId: number | string, + balance: bigint, + hasBalance: boolean, + error?: string + }> => { + try { + let balance: bigint; + const chainId = isEvmChain(chain) ? chain.id : chain.id; + + if (isEvmChain(chain)) { + // EVM chain balance check + const client = createPublicClient({ + chain, + transport: http(), + }); + + balance = await client.getBalance({ address: account.address }); + // Reduced balance threshold for testing (0.001 tokens instead of 0.01) + const hasBalance = balance > BigInt(1e14); + + return { + chainId, + balance, + hasBalance, + }; + } + else if (isSolanaChain(chain)) { + // Skip if Solana wallet not ready yet + if (!solanaReady || !solanaPublicKey) { + return { + chainId, + balance: BigInt(0), + hasBalance: false, + error: "Solana wallet still loading..." + }; + } + + // Solana chain balance check with fallback endpoints + const fallbackEndpoints = getSolanaFallbackEndpoints(chain); + + let lastError; + for (const endpoint of fallbackEndpoints) { + try { + const connection = new Connection(endpoint, chain.commitment); + const lamports = await connection.getBalance(solanaPublicKey, chain.commitment); + + // Convert lamports to bigint for consistency with EVM + balance = BigInt(lamports); + // Minimum balance threshold: 0.001 SOL (1,000,000 lamports) + const hasBalance = balance > BigInt(1_000_000); + + return { + chainId, + balance, + hasBalance, + }; + } catch (endpointError) { + console.warn(`Solana RPC ${endpoint} failed for ${chain.id}:`, endpointError); + lastError = endpointError; + continue; + } + } + + // If all endpoints failed, throw the last error + throw lastError || new Error(`All Solana RPC endpoints failed for ${chain.id}`); + } + else if (isSoonChain(chain)) { + // Skip if SOON wallet not ready yet + if (!soonPublicKey) { + return { + chainId, + balance: BigInt(0), + hasBalance: false, + error: "SOON wallet still loading..." + }; + } + + try { + const connection = new Connection(chain.endpoint, chain.commitment); + const lamports = await connection.getBalance(soonPublicKey, chain.commitment); + + // Convert lamports to bigint for consistency with EVM + balance = BigInt(lamports); + // Minimum balance threshold: 0.001 ETH (1,000,000 lamports for SOON) + const hasBalance = balance > BigInt(1_000_000); + + return { + chainId, + balance, + hasBalance, + }; + } catch (error) { + console.error(`SOON balance check failed for ${chain.id}:`, error); + throw error; + } + } else if (isFuelChain(chain)) { + // Skip if Fuel wallet not ready yet + if (!fuelReady || !fuelWallet) { + return { + chainId, + balance: BigInt(0), + hasBalance: false, + error: "Fuel wallet still loading..." + }; + } + + // Fuel balance check + const provider = new Provider(chain.rpcUrls.public.http[0]); + fuelWallet.connect(provider); + const fuelBalance = await fuelWallet.getBalance(); + // Convert BN to bigint for consistency + balance = BigInt(fuelBalance.toString()); + // Minimum balance threshold: 0.001 ETH (1e6 since Fuel uses 9 decimals) + const hasBalance = balance > BigInt(1e6); + + return { + chainId, + balance, + hasBalance, + }; + } + else if (isStarknetChain(chain)) { + // Starknet balance check + if (!starknetaccount?.address) { + return { + chainId, + balance: BigInt(0), + hasBalance: false, + error: "No Starknet account address available" + }; + } + + try { + const provider = new RpcProvider({ nodeUrl: chain.endpoint }); + const erc20Contract = new Contract(Erc20Abi, STRK_ADDRESS, provider); + + // Get the balance using the correct method + const starknetBalance = await erc20Contract.balance_of(starknetaccount.address); + + + // Convert BN to bigint for consistency + balance = BigInt(starknetBalance.toString()); + + // Minimum balance threshold: 0.02 STRK (20000000000000000 since STRK uses 18 decimals) + const hasBalance = balance > BigInt("20000000000000000"); + + return { + chainId, + balance, + hasBalance, + }; + } catch (error) { + console.error('🔍 [Chain Derby] Starknet balance check failed:', error); + return { + chainId, + balance: BigInt(0), + hasBalance: false, + error: (error as Error).message + }; + } + } + else if (isAptosChain(chain)) { + // Skip if Aptos wallet not ready yet + if (!aptosReady || !aptosAccount) { + return { + chainId, + balance: BigInt(0), + hasBalance: false, + error: "Aptos wallet still loading..." + }; + } + + // Aptos balance check + const config = new AptosConfig({ + network: chain.network as Network, + fullnode: chain.rpcUrl, + }); + const aptos = new Aptos(config); + + const balance = BigInt(await aptos.getAccountAPTAmount({accountAddress: aptosAccount.accountAddress})); + + // Minimum balance threshold: 0.001 APT (100,000 octas since APT uses 8 decimals) + const hasBalance = balance > BigInt(100_000); + + return { + chainId, + balance, + hasBalance, + }; + } else { + throw new Error(`Unsupported chain type: ${chainId}`); + } + } catch (error) { + console.error(`Failed to get balance for chain ${isEvmChain(chain) ? chain.id : chain.id} (attempt ${retryCount + 1}/${maxRetries + 1}):`, error); + + // Retry logic + if (retryCount < maxRetries) { + // Exponential backoff: 1s, 2s, 4s, etc. + const backoffTime = 1000 * Math.pow(2, retryCount); + await new Promise(resolve => setTimeout(resolve, backoffTime)); + return attemptBalanceCheck(retryCount + 1, maxRetries); + } + + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return { + chainId: isEvmChain(chain) ? chain.id : chain.id, + balance: BigInt(0), + hasBalance: false, + error: errorMessage, + }; + } + }; + + // Wrap in a timeout to ensure the promise resolves eventually + const timeoutPromise = new Promise<{ + chainId: number | string, + balance: bigint, + hasBalance: boolean, + error?: string + }>((_, reject) => { + setTimeout(() => reject(new Error(`RPC request timed out for ${chain.name}`)), 30000); + }); + + // Race the balance check with the timeout + return Promise.race([attemptBalanceCheck(), timeoutPromise]) + .catch(error => { + console.error(`Ultimate failure checking balance for ${chain.name}:`, error); + return { + chainId: isEvmChain(chain) ? chain.id : chain.id, + balance: BigInt(0), + hasBalance: false, + error: error instanceof Error + ? `Request failed: ${error.message}` + : "Unknown error checking balance", + }; + }); + }); + + const newBalances = await Promise.all(balancePromises); + + // Don't update state if component unmounted during the operation + if (!account) { + setIsLoadingBalances(false); + return; + } + + setBalances(newBalances); + + // Only consider selected chains for determining if all are funded + const selectedBalances = newBalances.filter(b => + selectedChains.includes(b.chainId) + ); + + const fundedChains = newBalances + .filter(b => b.hasBalance) + .map(b => b.chainId); + + const allSelectedFunded = selectedBalances.every(b => b.hasBalance); + + if (status !== "racing" && status !== "finished") { + if (allSelectedFunded && selectedBalances.length > 0) { + setStatus("ready"); + } else { + // Only remove unfunded chains from selection, don't auto-select all funded chains + const fundedSelectedChains = selectedChains.filter(chainId => + fundedChains.includes(chainId) + ); + + if (fundedSelectedChains.length > 0) { + setStatus("ready"); + // Only update selection if some selected chains became unfunded + if (fundedSelectedChains.length !== selectedChains.length) { + setSelectedChains(fundedSelectedChains); + } + } else { + setStatus("funding"); + } + } + } + } catch (error) { + console.error("Failed to check balances:", error); + if (status !== "racing" && status !== "finished") { + setStatus("funding"); + } + } finally { + setIsLoadingBalances(false); + } + }, [account, solanaPublicKey, solanaReady, fuelWallet, fuelReady, aptosAccount, aptosReady, soonPublicKey, starknetaccount, status, selectedChains, allChains]); + + // Effect to check initial balances + useEffect(() => { + const checkInitialBalances = async () => { + if (isLoadingBalances || status === "racing" || status === "finished") { + return; + } + + // Only require EVM wallet to be ready - other wallets can load independently + if (!account) { + return; + } + + try { + await checkBalances(); + } catch (error) { + console.error('Error checking balances:', error); + setIsLoadingBalances(false); + } + }; + + checkInitialBalances(); + }, [status, checkBalances, account, solanaReady, solanaPublicKey, fuelReady, fuelWallet, aptosReady, aptosAccount, soonPublicKey, starknetaccount]); + + + + // Effect to save race results when race finishes + useEffect(() => { + const saveResults = async () => { + if (status === 'finished' && results.length > 0 && account) { + try { + const isDevelopment = process.env.NODE_ENV === 'development'; + + if (isDevelopment) { + console.log('🏁 [Chain Derby] Race finished! Preparing to save results...'); + console.log('🔍 [Chain Derby] Results data:', results); + console.log('👤 [Chain Derby] Account:', account?.address); + console.log('🔢 [Chain Derby] Transaction count:', transactionCount); + } + + const geo = await getGeo(); + + if (isDevelopment) { + console.log('🌍 [Chain Derby] Geo location:', geo); + } + + // Convert results to the API payload format + const chainResults: ChainResultPayload[] = results.map(result => { + // Convert string chain IDs to numeric IDs for API compatibility + let numericChainId: number; + if (typeof result.chainId === 'string') { + if (result.chainId.includes('solana')) { + numericChainId = 999999; // Solana chains get ID 999999 + } else if (result.chainId.includes('aptos-testnet')) { + numericChainId = 999998; // Aptos testnet gets ID 999998 + } else if (result.chainId.includes('aptos-mainnet')) { + numericChainId = 999997; // Aptos mainnet gets ID 999997 + } else { + numericChainId = 999996; // Other string IDs get 999996 + } + } else { + numericChainId = result.chainId; + } + + return { + chainId: numericChainId, + chainName: result.name, + txLatencies: result.txLatencies, + averageLatency: result.averageLatency || 0, + totalLatency: result.totalLatency || 0, + status: result.status, + position: result.position, + }; + }); + + if (isDevelopment) { + console.log('⛓️ [Chain Derby] Processed chain results:', chainResults); + } + + const payload: RaceSessionPayload = { + title: `Chain Derby Race - ${new Date().toISOString()}`, + walletAddress: account.address, + transactionCount, + status: 'completed', + city: geo.city, + region: geo.region, + country: geo.country, + results: chainResults, + }; + + if (isDevelopment) { + console.log('📋 [Chain Derby] Final payload prepared:', payload); + console.log('🚀 [Chain Derby] Initiating API call...'); + } + + await saveRaceResults(payload); + + if (isDevelopment) { + console.log('🎉 [Chain Derby] Race results saved successfully!'); + } + } catch (error) { + const isDevelopment = process.env.NODE_ENV === 'development'; + + if (isDevelopment) { + console.error('❌ [Chain Derby] Failed to save race results:', error); + } + // Silently handle API failures - don't impact user experience + } + } + }; + + // Don't await this - let it run in background without blocking + saveResults(); + }, [status, results, account, transactionCount]); + + // Create a wallet client - defined at hook level to avoid ESLint warnings + const createClient = (chain: Chain) => { + if (!account) { + throw new Error("Cannot create wallet client: account is null"); + } + + return createWalletClient({ + account, + chain, + transport: http(), + }); + }; + + // Start the race across selected chains + const startRace = async () => { + if (!account || !privateKey || status !== "ready") return; + + setStatus("racing"); + + // Filter chains based on layer filter AND selection (support both EVM and Solana) + const filteredChains = getFilteredChains(); + const activeChains = filteredChains.filter(chain => + selectedChains.includes(isEvmChain(chain) ? chain.id : chain.id) + ); + + // Pre-fetch all chain data needed for transactions + const chainData = new Map(); + + try { + // Fetch chain data in parallel for selected chains + const chainDataPromises = activeChains.map(async (chain) => { + const chainId = isEvmChain(chain) ? chain.id : chain.id; + + try { + if (isEvmChain(chain)) { + // EVM chain data fetching + const client = createPublicClient({ + chain, + transport: http(), + }); + + // Run all required queries in parallel + const [nonce, feeData, blockData] = await Promise.all([ + // Get current nonce + client.getTransactionCount({ + address: account.address, + }), + + // Get current fee data and double it for better confirmation chances + client.getGasPrice().then(gasPrice => { + const doubledGasPrice = gasPrice * BigInt(3); + return doubledGasPrice; + }).catch(() => { + // Fallback gas prices based on known chain requirements + const fallbackGasPrice = BigInt( + chain.id === 10143 ? 60000000000 : // Monad has higher gas requirements + chain.id === 8453 ? 2000000000 : // Base mainnet + chain.id === 17180 ? 1500000000 : // Sonic + 1000000000 // Default fallback (1 gwei) + ); + return fallbackGasPrice; + }), + + // Get latest block to ensure we have chain state + client.getBlock().catch(() => null) + ]); + + // Create wallet client for transaction signing + const walletClient = createClient(chain); + + // Pre-sign all transactions + const signedTransactions = []; + + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Use Sepolia-like parameters for Monad since it's finicky + const txParams = { + to: account.address, + value: BigInt(0), + gas: 21000n, + gasPrice: feeData, + nonce: nonce + txIndex, + chainId: chain.id, + data: '0x' as const, // Use const assertion for hex string + }; + + const signedTx = await walletClient.signTransaction(txParams); + + if (!signedTx) { + throw new Error("Signing transaction returned null"); + } + + signedTransactions.push(signedTx); + } catch (signError) { + console.error(`Error signing tx #${txIndex} for ${chain.name}:`, signError); + // Push a placeholder so the array length still matches txIndex + signedTransactions.push(null); + } + } + + return { + chainId, + nonce, + gasPrice: feeData, + feeData, + blockData, + signedTransactions + }; + } else if (isSolanaChain(chain)) { + // Skip if Solana wallet not ready yet + if (!solanaKeypair) { + throw new Error(`Solana wallet not ready for ${chain.id}`); + } + + // Solana chain data fetching - try fallback endpoints to find working RPC + const fallbackEndpoints = getSolanaFallbackEndpoints(chain); + + let workingConnection = null; + for (const endpoint of fallbackEndpoints) { + try { + const connection = new Connection(endpoint, chain.commitment); + // Test the connection by getting latest blockhash + await connection.getLatestBlockhash(chain.commitment); + workingConnection = connection; + console.log(`Using Solana RPC ${endpoint} for ${chain.id}`); + break; + } catch (endpointError) { + console.warn(`Solana RPC ${endpoint} failed for ${chain.id} during setup:`, endpointError); + continue; + } + } + + if (!workingConnection) { + throw new Error(`All Solana RPC endpoints failed for ${chain.id} during setup`); + } + + // Pre-sign all Solana transactions + const signedTransactions = []; + + try { + // Get the latest blockhash for all transactions + const { blockhash } = await workingConnection.getLatestBlockhash(chain.commitment); + + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Create transaction with unique transfer amount to avoid duplicate signatures + const transaction = new Transaction(); + transaction.feePayer = solanaKeypair.publicKey; + transaction.recentBlockhash = blockhash; + transaction.add( + SystemProgram.transfer({ + fromPubkey: solanaKeypair.publicKey, + toPubkey: solanaKeypair.publicKey, + lamports: txIndex + 1, // Use different amounts to make transactions unique (1, 2, 3, etc.) + }) + ); + + // Sign the transaction + transaction.sign(solanaKeypair); + + // Serialize the signed transaction + const serializedTx = transaction.serialize(); + signedTransactions.push(serializedTx); + } catch (signError) { + console.error(`Error signing Solana tx #${txIndex} for ${chain.id}:`, signError); + signedTransactions.push(null); + } + } + } catch (blockhashError) { + console.error(`Error getting blockhash for Solana ${chain.id}:`, blockhashError); + } + + return { + chainId, + nonce: 0, // Not applicable for Solana + connection: workingConnection, + signedTransactions + }; + } else if (isSoonChain(chain)) { + // SOON chain data fetching - similar to Solana since it's SVM-based + if (!soonKeypair) { + throw new Error("SOON wallet not initialized"); + } + + try { + const connection = new Connection(chain.endpoint, chain.commitment); + // Test the connection by getting latest blockhash + await connection.getLatestBlockhash(chain.commitment); + console.log(`Using SOON RPC ${chain.endpoint} for ${chain.id}`); + + // Pre-sign all SOON transactions + const signedTransactions = []; + + try { + // Get the latest blockhash for all transactions + const { blockhash } = await connection.getLatestBlockhash(chain.commitment); + + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Create transaction with unique transfer amount to avoid duplicate signatures + const transaction = new Transaction(); + transaction.feePayer = soonKeypair.publicKey; + transaction.recentBlockhash = blockhash; + transaction.add( + SystemProgram.transfer({ + fromPubkey: soonKeypair.publicKey, + toPubkey: soonKeypair.publicKey, + lamports: txIndex + 1, // Use different amounts to make transactions unique + }) + ); + + // Sign the transaction + transaction.sign(soonKeypair); + + // Serialize the signed transaction + const serializedTx = transaction.serialize(); + signedTransactions.push(serializedTx); + } catch (signError) { + console.error(`Error signing SOON tx #${txIndex} for ${chain.id}:`, signError); + signedTransactions.push(null); + } + } + } catch (blockhashError) { + console.error(`Error getting blockhash for SOON ${chain.id}:`, blockhashError); + } + + return { + chainId, + nonce: 0, // Not applicable for SOON + connection, + signedTransactions + }; + } catch (error) { + throw new Error(`SOON RPC failed for ${chain.id} during setup: ${error instanceof Error ? error.message : String(error)}`); + } + } + else if (isStarknetChain(chain)) { + try { + const provider = new RpcProvider({ nodeUrl: chain.endpoint }); + const account = new Account( + provider, + starknetaccount?.address ?? "", + starknetprivateKey ?? "" + ); + return { + chainId, + nonce: Number(await account.getNonce()), + starknet: { + provider, + account + } + }; + } catch (error) { + throw new Error(`Starknet RPC failed for ${chain.id} during setup: ${error instanceof Error ? error.message : String(error)}`); + } + } + + else if (isFuelChain(chain)) { + // Skip if Fuel wallet not ready yet + if (!fuelWallet) { + throw new Error(`Fuel wallet not ready for ${chain.id}`); + } + + // Fuel chain data fetching + const provider = new Provider(chain.rpcUrls.public.http[0]); + const wallet = fuelWallet as WalletUnlocked; + wallet.connect(provider); + const baseAssetId = await provider.getBaseAssetId(); + const walletCoins = await wallet.getCoins(baseAssetId); + + // Find UTXOs with sufficient balance (greater than 10000) + const coins = walletCoins.coins as Coin[]; + const validUtxos = coins.filter(coin => { + const amount = coin.amount.toNumber(); // Convert BN to number + return amount > 10000; + }); + + if (validUtxos.length === 0) { + throw new Error("No UTXOs with sufficient balance found"); + } + + // Pre-sign only the first transaction + const signedTransactions = []; + try { + // Create transaction request with selected UTXO + const initialScriptRequest = new ScriptTransactionRequest({ + script: "0x" + }); + initialScriptRequest.maxFee = bn(100); + initialScriptRequest.addCoinInput(validUtxos[0]); + const initialSignedTx = await wallet.populateTransactionWitnessesSignature(initialScriptRequest); + signedTransactions.push(initialSignedTx); + } catch (signError) { + console.error(`Error signing first tx for Fuel chain:`, signError); + signedTransactions.push(null); + } + + return { + chainId, + nonce: 0, + wallet, + signedTransactions, + }; + } else if (isAptosChain(chain)) { + // Skip if Aptos wallet not ready yet + if (!aptosAccount) { + throw new Error(`Aptos wallet not ready for ${chain.id}`); + } + + // Aptos chain data fetching + const config = new AptosConfig({ + network: (chain as AptosChainConfig).network as Network, + fullnode: (chain as AptosChainConfig).rpcUrl, + }); + const aptos = new Aptos(config); + + // Fetch sequence number for the account + const accountData = await aptos.getAccountInfo({ accountAddress: aptosAccount.accountAddress }); + const sequenceNumber = BigInt(accountData.sequence_number); + + // Pre-sign all transactions + const buildAndSignTransaction = async (txIndex: number, aptosSeqNo: bigint) => { + const transaction = await aptos.transaction.build.simple({ + sender: aptosAccount.accountAddress, + data: { + function: "0x1::aptos_account::transfer", + functionArguments: [aptosAccount.accountAddress, new U64(0)], // Transfer 0 APT to self + abi: { + // ABI skips call to check arguments + signers: 1, + typeParameters: [], + parameters: [new TypeTagAddress(), new TypeTagU64()] + } + }, + options: { + accountSequenceNumber: aptosSeqNo! + BigInt(txIndex), + gasUnitPrice: 100, // Default gas price, no reason to estimate + maxGasAmount: 1000, // Set a max gas, no need for it to be too high + } + }) + return { + transaction, + senderAuthenticator: aptos.transaction.sign({ + signer: aptosAccount, + transaction, + }) + } + } + const signedTransactionPromises = []; + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + signedTransactionPromises.push(buildAndSignTransaction(txIndex, sequenceNumber)); + } + + const signedTransactions = await Promise.all(signedTransactionPromises) + + // Store the aptos client for transaction submission during race + return { + chainId, + nonce: 0, + aptos, + signedTransactions, + }; + } else { + throw new Error(`Unsupported chain type: ${chainId}`); + } + } catch (fetchError) { + console.error(`Failed to get chain data for ${chain.name}:`, fetchError); + + if (isEvmChain(chain)) { + // Use specific fallback gas prices based on chain + const fallbackGasPrice = BigInt( + chain.id === 10143 ? 60000000000 : // Monad has higher gas requirements + chain.id === 8453 ? 2000000000 : // Base mainnet + chain.id === 17180 ? 1500000000 : // Sonic + chain.id === 6342 ? 3000000000 : // MegaETH + 1000000000 // Default fallback (1 gwei) + ); + + return { + chainId, + nonce: 0, + gasPrice: fallbackGasPrice, + signedTransactions: [], + }; + } else { + // Solana fallback + return { + chainId, + nonce: 0, + signedTransactions: [], + }; + } + } + }); + + // Store fetched data in the Map + const results = await Promise.all(chainDataPromises); + results.forEach((data) => { + if (data && data.chainId !== undefined) { + chainData.set(data.chainId, data); + } + }); + } catch (error) { + console.error("Error prefetching chain data:", error); + } + + // Reset results for active chains only + const initialResults = activeChains.map(chain => ({ + chainId: isEvmChain(chain) ? chain.id : chain.id, + name: chain.name, + color: chain.color, + logo: chain.logo, // Add logo path from the chain config + status: "pending" as const, + txCompleted: 0, + txTotal: transactionCount, + txLatencies: [], // Empty array to store individual transaction latencies + })); + + setResults(initialResults); + + // Run transactions in parallel for each active chain + activeChains.forEach(async (chain) => { + const chainId = isEvmChain(chain) ? chain.id : chain.id; + + try { + // Update status to racing for this chain + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { ...r, status: "racing" } + : r + ) + ); + + if (isEvmChain(chain)) { + // EVM chain transaction processing + const publicClient = chain.id !== 11155931 ? + createPublicClient({ + chain, + transport: http(), + }) : null; + + // Run the specified number of transactions + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Skip if chain already had an error + const currentState = results.find(r => r.chainId === chainId); + if (currentState?.status === "error") { + break; + } + + let txHash: Hex; + let txLatency = 0; // Initialize txLatency to avoid reference error + const txStartTime = Date.now(); // Start time for this individual transaction + + // Get pre-fetched chain data including pre-signed transactions + // Using more specific fallback gas prices if chain data isn't available + const fallbackGasPrice = BigInt( + chain.id === 10143 ? 60000000000 : // Monad has higher gas requirements + chain.id === 8453 ? 2000000000 : // Base mainnet + chain.id === 6342 ? 3000000000 : // MegaETH + chain.id === 17180 ? 1500000000 : // Sonic + 1000000000 // Default fallback (1 gwei) + ); + + const currentChainData = chainData.get(chainId) || { + nonce: 0, + gasPrice: fallbackGasPrice, + signedTransactions: [] + }; + + // Get the pre-signed transaction for this index + const hasPreSignedTx = currentChainData.signedTransactions && + txIndex < currentChainData.signedTransactions.length && + currentChainData.signedTransactions[txIndex] !== null; + + // Use pre-signed transaction if available and not null + const signedTransaction = hasPreSignedTx + ? currentChainData.signedTransactions![txIndex] + : null; + + + if (chain.id === 11155931) { + // For RISE testnet, use the sync client with decorator pattern + const RISESyncClient = createPublicClient({ + chain, + transport: http(), + }).extend(syncActions); + + // Use pre-signed transaction if available, otherwise sign now + const txToSend = signedTransaction; + + + // Check if we have a valid transaction + if (!txToSend || typeof txToSend !== 'string') { + throw new Error(`Invalid transaction format for RISE tx #${txIndex}`); + } + + // Send the transaction and get receipt in one call + const receipt = await RISESyncClient.sendRawTransactionSync({ + serializedTransaction: txToSend as `0x${string}` + }); + + // Verify receipt + if (!receipt || !receipt.transactionHash) { + throw new Error(`RISE sync transaction sent but no receipt returned for tx #${txIndex}`); + } + txHash = receipt.transactionHash; + // Calculate transaction latency for RISE + const txEndTime = Date.now(); + txLatency = txEndTime - txStartTime; // Using outer txLatency variable here + } else if (chain.id === 6342) { + // For MegaETH testnet, use the custom realtime_sendRawTransaction method + + // Use pre-signed transaction if available, otherwise sign now + const txToSend = signedTransaction; + + // Check if we have a valid transaction + if (!txToSend || typeof txToSend !== 'string') { + throw new Error(`Invalid transaction format for MegaETH tx #${txIndex}`); + } + + // Explicitly verify the transaction is a valid string before sending + if (typeof txToSend !== 'string' || !txToSend.startsWith('0x')) { + throw new Error(`Invalid transaction format for MegaETH tx #${txIndex}: ${typeof txToSend}`); + } + + // Create a custom request to use the standard send transaction method + // MegaETH devs intended realtime_sendRawTransaction but it's not a standard method + const receipt = await publicClient!.request({ + // @ts-expect-error - MegaETH custom method not in standard types + method: 'realtime_sendRawTransaction', + params: [txToSend as `0x${string}`] + }) as TransactionReceipt | null; + + // The result is the transaction hash directly + if (!receipt) { + throw new Error(`MegaETH transaction sent but no hash returned for tx #${txIndex}`); + } + + txHash = receipt.transactionHash as Hex; + + // Calculate transaction latency for MegaETH + const txEndTime = Date.now(); + txLatency = txEndTime - txStartTime; + } else { + + // Use pre-signed transaction if available, otherwise sign now + const txToSend = signedTransaction; + + // Critical null safety check + if (!txToSend) { + throw new Error(`No transaction to send for ${chain.name} tx #${txIndex}`); + } + + + // Explicitly verify the transaction is a valid string before sending + if (typeof txToSend !== 'string' || !txToSend.startsWith('0x')) { + throw new Error(`Invalid transaction format for ${chain.name} tx #${txIndex}: ${typeof txToSend}`); + } + + // Normal path for non-Monad chains + // Send the raw transaction - wagmi v2 changed the API + txHash = await publicClient!.sendRawTransaction({ + serializedTransaction: txToSend as `0x${string}` + }); + + if (!txHash) { + throw new Error(`Transaction sent but no hash returned for ${chain.name} tx #${txIndex}`); + } + + } + + // Update result with transaction hash + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { ...r, txHash } // Just store the latest hash + : r + ) + ); + + // For non-RISE and non-MegaETH chains, we need to wait for confirmation + if (chain.id !== 11155931 && chain.id !== 6342) { + // Wait for transaction to be confirmed + await publicClient!.waitForTransactionReceipt({ + pollingInterval: 50, // 50ms to avoid rate limits + retryDelay: 1, // 1ms + hash: txHash, + timeout: 60_000, // 60 seconds timeout + }); + + // Calculate total transaction latency from start to confirmation + const txEndTime = Date.now(); + txLatency = txEndTime - txStartTime; + } + + // Transaction confirmed, update completed count and track latencies for all chains + setResults((prev) => { + const updatedResults = prev.map(r => { + if (r.chainId === chainId) { + // Add this transaction's latency to the array + const newLatencies = [...r.txLatencies, txLatency]; + + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; + + // Calculate total and average latency if we have latencies + const totalLatency = newLatencies.length > 0 + ? newLatencies.reduce((sum, val) => sum + val, 0) + : undefined; + + const averageLatency = totalLatency !== undefined + ? Math.round(totalLatency / newLatencies.length) + : undefined; + + + // Ensure status is one of the allowed values from RaceResult.status type + const newStatus: "pending" | "racing" | "success" | "error" = + allTxCompleted ? "success" : "racing"; + + return { + ...r, + txCompleted, + status: newStatus, + txLatencies: newLatencies, + averageLatency, + totalLatency + }; + } + return r; + }); + + // Only determine rankings when chains finish all transactions + const finishedResults = updatedResults + .filter(r => r.status === "success") + .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); + + // Assign positions to finished results + finishedResults.forEach((result, idx) => { + const position = idx + 1; + updatedResults.forEach((r, i) => { + if (r.chainId === result.chainId) { + updatedResults[i] = { ...r, position }; + } + }); + }); + + return updatedResults; + }); + } catch (error) { + console.error(`Race error for chain ${chain.id}, tx #${txIndex}:`, error); + + // Provide a more user-friendly error message + let errorMessage = "Transaction failed"; + + if (error instanceof Error) { + // Extract the most useful part of the error message + const fullMessage = error.message; + + if (fullMessage.includes("Invalid params")) { + errorMessage = "Invalid transaction parameters. Chain may require specific gas settings."; + } else if (fullMessage.includes("insufficient funds")) { + errorMessage = "Insufficient funds for gas + value."; + } else if (fullMessage.includes("nonce too low")) { + errorMessage = "Transaction nonce issue. Try again with a new wallet."; + } else if (fullMessage.includes("timeout")) { + errorMessage = "Network timeout. Chain may be congested."; + } else { + // Use the first line of the error message if available + const firstLine = fullMessage.split('\n')[0]; + errorMessage = firstLine || fullMessage; + } + } + + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: errorMessage + } + : r + ) + ); + break; // Stop sending transactions for this chain if there's an error + } + } + + } else if (isSolanaChain(chain)) { + // Skip if Solana wallet not ready yet + if (!solanaKeypair) { + console.error(`Solana wallet not ready for ${chainId}`); + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: "Solana wallet not ready" + } + : r + ) + ); + return; + } + + // Solana chain transaction processing + const currentChainData = chainData.get(chainId); + + if (!currentChainData || !currentChainData.connection) { + console.error(`No connection data for Solana chain ${chainId}`); + return; + } + + // Run the specified number of transactions + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Skip if chain already had an error + const currentState = results.find(r => r.chainId === chainId); + if (currentState?.status === "error") { + break; + } + + let txLatency = 0; + let signature: string; + const txStartTime = Date.now(); + + // Get the pre-signed transaction for this index + const hasPreSignedTx = currentChainData.signedTransactions && + txIndex < currentChainData.signedTransactions.length && + currentChainData.signedTransactions[txIndex] !== null; + + if (hasPreSignedTx) { + // Use pre-signed transaction (we know it exists due to hasPreSignedTx check) + const serializedTransaction = currentChainData.signedTransactions![txIndex] as Buffer; + + // Send the pre-signed transaction + signature = await currentChainData.connection.sendRawTransaction( + serializedTransaction, + { + skipPreflight: false, + preflightCommitment: (chain as SolanaChainConfig).commitment, + } + ); + + // Wait for confirmation + await currentChainData.connection.confirmTransaction( + signature, + (chain as SolanaChainConfig).commitment + ); + + // Calculate transaction latency + const txEndTime = Date.now(); + txLatency = txEndTime - txStartTime; + + // Update result with transaction signature + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { ...r, signature } // Store Solana signature + : r + ) + ); + } else { + // Fallback: create fresh transaction if no pre-signed transaction available + console.warn(`No pre-signed transaction for Solana tx #${txIndex}, creating fresh transaction`); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: solanaKeypair.publicKey, + toPubkey: solanaKeypair.publicKey, + lamports: txIndex + 1, // Use different amounts to make transactions unique + }) + ); + + signature = await sendAndConfirmTransaction( + currentChainData.connection, + transaction, + [solanaKeypair], + { + commitment: (chain as SolanaChainConfig).commitment, + preflightCommitment: (chain as SolanaChainConfig).commitment, + } + ); + + // Calculate transaction latency + const txEndTime = Date.now(); + txLatency = txEndTime - txStartTime; + + // Update result with transaction signature + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { ...r, signature } // Store Solana signature + : r + ) + ); + } + + // Transaction confirmed, update completed count and track latencies + setResults((prev) => { + const updatedResults = prev.map(r => { + if (r.chainId === chainId) { + // Add this transaction's latency to the array + const newLatencies = [...r.txLatencies, txLatency]; + + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; + + // Calculate total and average latency if we have latencies + const totalLatency = newLatencies.length > 0 + ? newLatencies.reduce((sum, val) => sum + val, 0) + : undefined; + + const averageLatency = totalLatency !== undefined + ? Math.round(totalLatency / newLatencies.length) + : undefined; + + // Ensure status is one of the allowed values from RaceResult.status type + const newStatus: "pending" | "racing" | "success" | "error" = + allTxCompleted ? "success" : "racing"; + + return { + ...r, + txCompleted, + status: newStatus, + txLatencies: newLatencies, + averageLatency, + totalLatency + }; + } + return r; + }); + + // Only determine rankings when chains finish all transactions + const finishedResults = updatedResults + .filter(r => r.status === "success") + .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); + + // Assign positions to finished results + finishedResults.forEach((result, idx) => { + const position = idx + 1; + updatedResults.forEach((r, i) => { + if (r.chainId === result.chainId) { + updatedResults[i] = { ...r, position }; + } + }); + }); + + return updatedResults; + }); + } catch (error) { + console.error(`Solana race error for chain ${chainId}, tx #${txIndex}:`, error); + + // Provide a more user-friendly error message + let errorMessage = "Solana transaction failed"; + + if (error instanceof Error) { + const fullMessage = error.message; + + if (fullMessage.includes("insufficient funds")) { + errorMessage = "Insufficient SOL for transaction fees."; + } else if (fullMessage.includes("blockhash not found")) { + errorMessage = "Transaction expired. Please try again."; + } else if (fullMessage.includes("timeout")) { + errorMessage = "Solana network timeout. Please try again."; + } else { + // Use the first line of the error message if available + const firstLine = fullMessage.split('\n')[0]; + errorMessage = firstLine || fullMessage; + } + } + + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: errorMessage + } + : r + ) + ); + break; // Stop sending transactions for this chain if there's an error + } + } + } else if (isSoonChain(chain)) { + // SOON chain transaction processing - similar to Solana since it's SVM-based + const currentChainData = chainData.get(chainId); + + if (!currentChainData || !currentChainData.connection) { + console.error(`No connection data for SOON chain ${chainId}`); + return; + } + + // Run the specified number of transactions + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Skip if chain already had an error + const currentState = results.find(r => r.chainId === chainId); + if (currentState?.status === "error") { + break; + } + + let txLatency = 0; + let signature: string; + const txStartTime = Date.now(); + + // Get the pre-signed transaction for this index + const hasPreSignedTx = currentChainData.signedTransactions && + txIndex < currentChainData.signedTransactions.length && + currentChainData.signedTransactions[txIndex] !== null; + + if (hasPreSignedTx) { + // Use pre-signed transaction + const serializedTransaction = currentChainData.signedTransactions![txIndex] as Buffer; + + // Send the pre-signed transaction + signature = await currentChainData.connection.sendRawTransaction( + serializedTransaction, + { + skipPreflight: false, + preflightCommitment: (chain as SoonChainConfig).commitment, + } + ); + + // Wait for confirmation + await currentChainData.connection.confirmTransaction( + signature, + (chain as SoonChainConfig).commitment + ); + + // Calculate transaction latency + txLatency = Date.now() - txStartTime; + } else { + console.warn(`No pre-signed transaction found for SOON tx #${txIndex}, skipping`); + continue; + } + + // Store the signature hash for this specific transaction + if (txIndex === 0) { + // Store signature in the first transaction only to avoid overwriting + setResults((prev) => + prev.map((r) => + r.chainId === chainId + ? { ...r, signature } // Store SOON signature + : r + ) + ); + } + + // Transaction confirmed, update completed count and track latencies + setResults((prev) => { + const updatedResults = prev.map(r => { + if (r.chainId === chainId) { + // Add this transaction's latency to the array + const newLatencies = [...r.txLatencies, txLatency]; + + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; + + // Calculate total and average latency if we have latencies + const totalLatency = newLatencies.length > 0 + ? newLatencies.reduce((sum, val) => sum + val, 0) + : undefined; + + const averageLatency = totalLatency !== undefined + ? Math.round(totalLatency / newLatencies.length) + : undefined; + + // Ensure status is one of the allowed values from RaceResult.status type + const newStatus: "pending" | "racing" | "success" | "error" = + allTxCompleted ? "success" : "racing"; + + return { + ...r, + txCompleted, + status: newStatus, + txLatencies: newLatencies, + averageLatency, + totalLatency + }; + } + return r; + }); + + return updatedResults; + }); + + } catch (error) { + console.error(`SOON transaction ${txIndex} failed for ${chain.id}:`, error); + // Mark this chain as having an error + setResults((prev) => + prev.map((r) => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: error instanceof Error ? error.message : String(error), + } + : r + ) + ); + break; // Stop sending transactions for this chain if there's an error + } + } + } else if (isFuelChain(chain)) { + // Fuel chain transaction processing + const currentChainData = chainData.get(chainId); + + if (!currentChainData) { + console.error(`No wallet data for Fuel chain ${chainId}`); + return; + } + + const fuelWalletUnlocked = fuelWallet as WalletUnlocked; + const provider = new Provider(chain.rpcUrls.public.http[0]); + fuelWalletUnlocked.connect(provider); + const baseAssetId = await provider.getBaseAssetId(); + let lastETHResolvedOutput: ResolvedOutput[] | null = null; + + // Run the specified number of transactions + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Skip if chain already had an error + const currentState = results.find(r => r.chainId === chainId); + if (currentState?.status === "error") { + break; + } + + let txLatency = 0; + const txStartTime = Date.now(); + let tx; + + if (txIndex === 0) { + // First transaction - use pre-signed transaction + if (!currentChainData.signedTransactions) { + throw new Error("No pre-signed transaction available"); + } + const signedTransaction = currentChainData.signedTransactions[0]; + if (!signedTransaction) { + throw new Error("No pre-signed transaction available"); + } + tx = await provider.sendTransaction(signedTransaction as TransactionRequest, { estimateTxDependencies: false }); + + const preConfOutput = await tx.waitForPreConfirmation(); + if (preConfOutput.resolvedOutputs) { + const ethUTXO = preConfOutput.resolvedOutputs.find( + (output) => (output.output as OutputChange).assetId === baseAssetId + ); + if (ethUTXO) { + lastETHResolvedOutput = [ethUTXO]; + } + } + } else { + // Subsequent transactions using previous UTXO + if (!lastETHResolvedOutput || lastETHResolvedOutput.length === 0) { + throw new Error("No resolved output available for subsequent transaction"); + } + + const scriptRequest = new ScriptTransactionRequest({ + script: "0x" + }); + scriptRequest.maxFee = bn(100); + + const [{ utxoId, output }] = lastETHResolvedOutput; + const change = output as unknown as { + assetId: string; + amount: string; + }; + + const resource = { + id: utxoId, + assetId: change.assetId, + amount: bn(change.amount), + owner: fuelWalletUnlocked.address, + blockCreated: bn(0), + txCreatedIdx: bn(0), + }; + + scriptRequest.addResource(resource); + const signedTransaction = await fuelWalletUnlocked.populateTransactionWitnessesSignature(scriptRequest); + tx = await provider.sendTransaction(signedTransaction as TransactionRequest, { estimateTxDependencies: false }); + + const preConfOutput = await tx.waitForPreConfirmation(); + if (preConfOutput.resolvedOutputs) { + const ethUTXO = preConfOutput.resolvedOutputs.find( + (output) => (output.output as OutputChange).assetId === baseAssetId + ); + if (ethUTXO) { + lastETHResolvedOutput = [ethUTXO]; + } + } + } + + if (!tx) { + throw new Error("Failed to send transaction"); + } + + // Calculate transaction latency + const txEndTime = Date.now(); + txLatency = txEndTime - txStartTime; + + // Update result with transaction hash + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { ...r, txHash: `0x${tx.id}` } + : r + ) + ); + + // Transaction confirmed, update completed count and track latencies + setResults((prev) => { + const updatedResults = prev.map(r => { + if (r.chainId === chainId) { + const newLatencies = [...r.txLatencies, txLatency]; + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; + + const totalLatency = newLatencies.length > 0 + ? newLatencies.reduce((sum, val) => sum + val, 0) + : undefined; + + const averageLatency = totalLatency !== undefined + ? Math.round(totalLatency / newLatencies.length) + : undefined; + + const newStatus: "pending" | "racing" | "success" | "error" = + allTxCompleted ? "success" : "racing"; + + return { + ...r, + txCompleted, + status: newStatus, + txLatencies: newLatencies, + averageLatency, + totalLatency + }; + } + return r; + }); + + // Only determine rankings when chains finish all transactions + const finishedResults = updatedResults + .filter(r => r.status === "success") + .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); + + // Assign positions to finished results + finishedResults.forEach((result, idx) => { + const position = idx + 1; + updatedResults.forEach((r, i) => { + if (r.chainId === result.chainId) { + updatedResults[i] = { ...r, position }; + } + }); + }); + + return updatedResults; + }); + } catch (error) { + console.error(`Fuel race error for chain ${chainId}, tx #${txIndex}:`, error); + + let errorMessage = "Fuel transaction failed"; + + if (error instanceof Error) { + const fullMessage = error.message; + + if (fullMessage.includes("insufficient funds")) { + errorMessage = "Insufficient ETH for transaction fees."; + } else if (fullMessage.includes("timeout")) { + errorMessage = "Fuel network timeout. Please try again."; + } else { + const firstLine = fullMessage.split('\n')[0]; + errorMessage = firstLine || fullMessage; + } + } + + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: errorMessage + } + : r + ) + ); + break; + } + } + } else if (isAptosChain(chain)) { + // Aptos chain transaction processing + const currentChainData = chainData.get(chainId); + + if (!currentChainData || !currentChainData.aptos || !currentChainData.signedTransactions) { + console.error(`No Aptos client data for chain ${chainId}`); + return; + } + + const aptos = currentChainData.aptos; + + // Run the specified number of transactions + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + try { + // Skip if chain already had an error + const currentState = results.find(r => r.chainId === chainId); + if (currentState?.status === "error") { + break; + } + + let txLatency = 0; + const txStartTime = Date.now(); + + // Sign and submit the transaction + const signedTransaction = currentChainData.signedTransactions[txIndex]; + if (!signedTransaction || typeof signedTransaction !== "object") { + throw new Error(`No pre-signed transaction available for Aptos tx #${txIndex}`); + } else if (typeof signedTransaction === "object" && !("senderAuthenticator" in signedTransaction)) { + console.error(`Signed transaction for Aptos tx #${txIndex} is missing senderAuthenticator`); + return; + } + + const response = await aptos.transaction.submit.simple(signedTransaction); + + // Wait for transaction confirmation + await aptos.waitForTransaction({ + transactionHash: response.hash, + options: { + waitForIndexer: false // Unnecessary, no indexer calls made + } + }); + + // Calculate transaction latency + const txEndTime = Date.now(); + txLatency = txEndTime - txStartTime; + + // Update result with transaction hash + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { ...r, txHash: response.hash.startsWith('0x') ? response.hash as Hex : `0x${response.hash}` as Hex } + : r + ) + ); + + // Transaction confirmed, update completed count and track latencies + setResults((prev) => { + const updatedResults = prev.map(r => { + if (r.chainId === chainId) { + const newLatencies = [...r.txLatencies, txLatency]; + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; + + const totalLatency = newLatencies.length > 0 + ? newLatencies.reduce((sum, val) => sum + val, 0) + : undefined; + + const averageLatency = totalLatency !== undefined + ? Math.round(totalLatency / newLatencies.length) + : undefined; + + const newStatus: "pending" | "racing" | "success" | "error" = + allTxCompleted ? "success" : "racing"; + + return { + ...r, + txCompleted, + status: newStatus, + txLatencies: newLatencies, + averageLatency, + totalLatency + }; + } + return r; + }); + + // Only determine rankings when chains finish all transactions + const finishedResults = updatedResults + .filter(r => r.status === "success") + .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); + + // Assign positions to finished results + finishedResults.forEach((result, idx) => { + const position = idx + 1; + updatedResults.forEach((r, i) => { + if (r.chainId === result.chainId) { + updatedResults[i] = { ...r, position }; + } + }); + }); + + return updatedResults; + }); + } catch (error) { + console.error(`Aptos race error for chain ${chainId}, tx #${txIndex}:`, error); + + let errorMessage = "Aptos transaction failed"; + + if (error instanceof Error) { + const fullMessage = error.message; + + if (fullMessage.includes("insufficient funds")) { + errorMessage = "Insufficient APT for transaction fees."; + } else if (fullMessage.includes("timeout")) { + errorMessage = "Aptos network timeout. Please try again."; + } else if (fullMessage.includes("SEQUENCE_NUMBER_TOO_OLD")) { + errorMessage = "Transaction sequence error. Please try again."; + } else { + const firstLine = fullMessage.split('\n')[0]; + errorMessage = firstLine || fullMessage; + } + } + + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: errorMessage + } + : r + ) + ); + break; + } + } + } else if (isStarknetChain(chain)) { + const currentChainData = chainData.get(chainId); + + if (!currentChainData) { + console.error(`No wallet data for Starknet chain ${chainId}`); + return; + } + + const provider = new RpcProvider({ nodeUrl: chain.endpoint }); + const account = new Account( + provider, + starknetaccount?.address ?? "", + starknetprivateKey ?? "" + ); + const erc20Contract = new Contract(Erc20Abi, STRK_ADDRESS, account); + + for (let txIndex = 0; txIndex < transactionCount; txIndex++) { + const currentNonce = await account.getNonce(); + try { + // Skip if chain already had an error + const currentState = results.find(r => r.chainId === chainId); + if (currentState?.status === "error") { + break; + } + + const startTime = Date.now(); + const amount = cairo.uint256((txIndex + 1) * 10 ** 18); + + const transferCall: Call = erc20Contract.populate("transfer", { + recipient: starknetaccount?.address ?? "", + amount: amount, + }); + + const { transaction_hash: transferTxHash } = await account.execute( + transferCall, + { + nonce: currentNonce, + version: 3, + } + ); + // Wait for transaction confirmation + await provider.waitForTransaction(transferTxHash); + // Calculate transaction latency + const endTime = Date.now(); + const txLatency = endTime - startTime; + + // Update results with transaction hash and latency + setResults(prev => + prev.map(r => { + if (r.chainId === chainId) { + const newLatencies = [...r.txLatencies, txLatency]; + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; + + const totalLatency = newLatencies.reduce((sum, val) => sum + val, 0); + const averageLatency = Math.round(totalLatency / newLatencies.length); + + return { + ...r, + txHash: transferTxHash as `0x${string}`, + txCompleted, + status: allTxCompleted ? "success" : "racing", + txLatencies: newLatencies, + averageLatency, + totalLatency + }; + } + return r; + }) + ); + + } catch (error) { + console.error(`Starknet race error for chain ${chainId}, tx #${txIndex}:`, error); + + let errorMessage = "Starknet transaction failed"; + if (error instanceof Error) { + const fullMessage = error.message; + if (fullMessage.includes("insufficient funds")) { + errorMessage = "Insufficient STRK for transaction fees."; + } else if (fullMessage.includes("nonce")) { + errorMessage = "Transaction nonce issue. Please try again."; + } else if (fullMessage.includes("timeout")) { + errorMessage = "Starknet network timeout. Please try again."; + } else { + const firstLine = fullMessage.split('\n')[0]; + errorMessage = firstLine || fullMessage; + } + } + + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: errorMessage + } + : r + ) + ); + break; + } + } + } + + } catch (error) { + console.error(`Race initialization error for chain ${chainId}:`, error); + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "error" as const, + error: error instanceof Error ? error.message : "Race initialization failed" + } + : r + ) + ); + } + }); + + // Check if race is complete periodically + const checkRaceComplete = setInterval(() => { + setResults(prev => { + const allDone = prev.every(r => + r.status === "success" || r.status === "error" || r.txCompleted >= transactionCount + ); + + if (allDone) { + setStatus("finished"); + clearInterval(checkRaceComplete); + } + return prev; + }); + }, 1000); + }; + + // Reset everything to prepare for a new race + const resetRace = () => { + setStatus("idle"); + setBalances([]); + setResults([]); + }; + + // Start a new race with the same configuration (when already in finished state) + const restartRace = () => { + // Keep the balances but reset the results + setStatus("ready"); + setResults([]); + }; + + // Skip a specific chain during the race + const skipChain = (chainId: number | string) => { + setResults(prev => + prev.map(r => + r.chainId === chainId + ? { + ...r, + status: "success" as const, // Use const assertion to ensure correct type + txCompleted: r.txTotal, // Mark all transactions as completed + position: 999, // Put it at the end of the results + error: "Skipped by user" + } + : r + ) + ); + }; + + return { + status, + balances, + results, + isLoadingBalances, + checkBalances, + startRace, + resetRace, + restartRace, + skipChain, + isReady, + account, + privateKey, + transactionCount, + setTransactionCount, + resetWallet, + selectedChains, + setSelectedChains, + // Layer filtering + layerFilter, + setLayerFilter, + // Network filtering + networkFilter, + setNetworkFilter, + getFilteredChains, + // Solana wallet information + solanaPublicKey, + solanaKeypair, + solanaReady, + // Fuel wallet information + fuelWallet, + fuelReady, + // Aptos wallet information + aptosAccount, + aptosAddress, + aptosReady, + // Starknet wallet information + starknetaccount, + starknetprivateKey, + starknetisReady, + }; +} \ No newline at end of file diff --git a/packages/app/src/hooks/useChainRace.ts b/packages/app/src/hooks/useChainRace.ts index 66296c1..cfecb03 100644 --- a/packages/app/src/hooks/useChainRace.ts +++ b/packages/app/src/hooks/useChainRace.ts @@ -1,75 +1,49 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { createPublicClient, createWalletClient, http, type Hex, type Chain, TransactionReceipt } from "viem"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import type { Hex } from "viem"; import { allChains, type AnyChainConfig } from "@/chain/networks"; import { useEmbeddedWallet } from "./useEmbeddedWallet"; import { useSolanaEmbeddedWallet } from "./useSolanaEmbeddedWallet"; import { useFuelEmbeddedWallet } from "./useFuelEmbeddedWallet"; import { useAptosEmbeddedWallet } from "./useAptosEmbeddedWallet"; import { useSoonEmbeddedWallet } from "./useSoonEmbeddedWallet"; -import { syncActions } from "shreds/viem"; -import { Connection, SystemProgram, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; -import type { SolanaChainConfig } from "@/solana/config"; -import type { FuelChainConfig } from "@/fuel/config"; -import type { AptosChainConfig } from "@/aptos/config"; -import type { SoonChainConfig } from "@/soon/config"; -import { - Aptos, - AptosConfig, - Network, - type SimpleTransaction, - type AccountAuthenticator, - TypeTagAddress, TypeTagU64, U64, -} from "@aptos-labs/ts-sdk"; +import { useStarknetEmbeddedWallet } from "./useStarknetEmbeddedWallet"; import { getGeo } from "@/lib/geo"; import { saveRaceResults } from "@/lib/api"; -import { WalletUnlocked, bn, Provider, type TransactionRequest, ScriptTransactionRequest, type Coin, ResolvedOutput, OutputChange } from "fuels"; -import { useStarknetEmbeddedWallet } from "./useStarknetEmbeddedWallet"; -import { StarknetChainConfig } from "@/starknet/config"; -import { Erc20Abi } from "../util/erc20abi"; -import { STRK_ADDRESS } from "../util/erc20Contract"; -import { - cairo, - Call, - Contract, - RpcProvider, -} from "starknet"; -import { Account } from "starknet"; - +import { ChainAdapterFactory, type WalletStates } from "./adapters/ChainAdapterFactory"; +import type { ChainAdapter } from "./adapters/base/ChainAdapter.interface"; +// Re-export types from original hook for compatibility export type ChainRaceStatus = "idle" | "funding" | "ready" | "racing" | "finished"; +export type TransactionCount = 1 | 5 | 10 | 20; +export type LayerFilter = 'L1' | 'L2' | 'Both'; +export type NetworkFilter = 'Mainnet' | 'Testnet'; export interface ChainBalance { - chainId: number | string; // Support both EVM (number) and Solana (string) chain IDs + chainId: number | string; balance: bigint; hasBalance: boolean; error?: string; } export interface RaceResult { - chainId: number | string; // Support both EVM (number) and Solana (string) chain IDs + chainId: number | string; name: string; color: string; - logo?: string; // Path to the chain logo + logo?: string; status: "pending" | "racing" | "success" | "error"; - txHash?: Hex; // EVM transaction hash - signature?: string; // Solana transaction signature + txHash?: Hex; + signature?: string; error?: string; position?: number; - txCompleted: number; // Count of completed transactions - txTotal: number; // Total transactions required - txLatencies: number[]; // Array of individual transaction latencies in ms - averageLatency?: number; // Average transaction latency - totalLatency?: number; // Total latency of all transactions combined + txCompleted: number; + txTotal: number; + txLatencies: number[]; + averageLatency?: number; + totalLatency?: number; } -export type TransactionCount = 1 | 5 | 10 | 20; - -export type LayerFilter = 'L1' | 'L2' | 'Both'; - -export type NetworkFilter = 'Mainnet' | 'Testnet'; - export interface RaceSessionPayload { title: string; walletAddress: string; @@ -84,7 +58,7 @@ export interface RaceSessionPayload { export interface ChainResultPayload { chainId: number; chainName: string; - txLatencies: number[]; // raw per-tx times + txLatencies: number[]; averageLatency: number; totalLatency: number; status: string; @@ -97,78 +71,33 @@ const LOCAL_STORAGE_TX_COUNT = "horse-race-tx-count"; const LOCAL_STORAGE_LAYER_FILTER = "horse-race-layer-filter"; const LOCAL_STORAGE_NETWORK_FILTER = "horse-race-network-filter"; -// Helper functions to distinguish chain types -function isEvmChain(chain: AnyChainConfig): chain is Chain & { testnet: boolean; color: string; logo: string; faucetUrl?: string; layer: 'L1' | 'L2'; } { - return 'id' in chain && typeof chain.id === 'number'; -} - -function isSolanaChain(chain: AnyChainConfig): chain is SolanaChainConfig { - return 'cluster' in chain; -} - -function isFuelChain(chain: AnyChainConfig): chain is FuelChainConfig { - return chain.name === "Fuel Testnet" || chain.name === "Fuel Mainnet"; -} - -function isAptosChain(chain: AnyChainConfig): chain is AptosChainConfig { - return 'network' in chain && typeof chain.network === 'string' && - ('id' in chain && typeof chain.id === 'string' && chain.id.startsWith('aptos-')); -} - -function isSoonChain(chain: AnyChainConfig): chain is SoonChainConfig { - return 'id' in chain && typeof chain.id === 'string' && chain.id.startsWith('soon-'); -} - -function isStarknetChain(chain: AnyChainConfig): chain is StarknetChainConfig { - return chain.id === "starknet-testnet" || chain.id === "starknet-mainnet"; -} - -// Helper function to get fallback RPC endpoints for Solana -function getSolanaFallbackEndpoints(chain: SolanaChainConfig): string[] { - const fallbackEndpoints = [ - chain.endpoint, - // Fallback RPC endpoints for Solana - ...(chain.id === 'solana-mainnet' ? [ - 'https://api.mainnet-beta.solana.com', - 'https://solana-api.projectserum.com', - 'https://rpc.ankr.com/solana', - ] : chain.id === 'solana-devnet' ? [ - 'https://api.devnet.solana.com', - ] : [ - 'https://api.testnet.solana.com', - ]) - ]; - return fallbackEndpoints; +// Helper function to get chain ID consistently +function getChainId(chain: AnyChainConfig): number | string { + return 'id' in chain ? chain.id : (chain as any).id; } export function useChainRace() { + // All wallet hooks const { account, privateKey, isReady, resetWallet } = useEmbeddedWallet(); const { publicKey: solanaPublicKey, keypair: solanaKeypair, isReady: solanaReady } = useSolanaEmbeddedWallet(); const { wallet: fuelWallet, isReady: fuelReady } = useFuelEmbeddedWallet(); const { account: aptosAccount, address: aptosAddress, isReady: aptosReady } = useAptosEmbeddedWallet(); - const { publicKey: soonPublicKey, keypair: soonKeypair } = useSoonEmbeddedWallet(); + const { publicKey: soonPublicKey, keypair: soonKeypair } = useSoonEmbeddedWallet(); const { starknetprivateKey, starknetaccount, starknetisReady } = useStarknetEmbeddedWallet(); + + // Core state const [status, setStatus] = useState("idle"); const [balances, setBalances] = useState([]); const [results, setResults] = useState([]); const [isLoadingBalances, setIsLoadingBalances] = useState(false); + // Settings state with localStorage persistence const [transactionCount, setTransactionCount] = useState(() => { - // Load saved transaction count from localStorage if available - // if (typeof window !== 'undefined') { - // const savedCount = localStorage.getItem(LOCAL_STORAGE_TX_COUNT); - // if (savedCount) { - // const count = parseInt(savedCount, 10) as TransactionCount; - // if ([1, 5, 10, 20].includes(count)) { - // return count; - // } - // } - // } + // Commented out localStorage loading for now as in original return 10; }); const [layerFilter, setLayerFilter] = useState(() => { - // Load saved layer filter from localStorage if available if (typeof window !== 'undefined') { const savedFilter = localStorage.getItem(LOCAL_STORAGE_LAYER_FILTER); if (savedFilter && ['L1', 'L2', 'Both'].includes(savedFilter)) { @@ -179,25 +108,22 @@ export function useChainRace() { }); const [networkFilter, setNetworkFilter] = useState(() => { - // Load saved network filter from localStorage if available if (typeof window !== 'undefined') { const savedFilter = localStorage.getItem(LOCAL_STORAGE_NETWORK_FILTER); if (savedFilter && ['Mainnet', 'Testnet'].includes(savedFilter)) { return savedFilter as NetworkFilter; } } - return 'Testnet'; // Default to testnet for safety + return 'Testnet'; }); const [selectedChains, setSelectedChains] = useState<(number | string)[]>(() => { - // Load saved chain selection from localStorage if available if (typeof window !== 'undefined') { const savedChains = localStorage.getItem(LOCAL_STORAGE_SELECTED_CHAINS); if (savedChains) { try { const parsed = JSON.parse(savedChains) as (number | string)[]; - // Validate that all chains in the saved list are actually valid chains - const validChainIds: (number | string)[] = allChains.map(chain => isEvmChain(chain) ? chain.id : chain.id); + const validChainIds: (number | string)[] = allChains.map(getChainId); const validSavedChains = parsed.filter(id => validChainIds.includes(id)); if (validSavedChains.length > 0) { @@ -208,96 +134,98 @@ export function useChainRace() { } } } - // Default to all chains (EVM + Solana) - return allChains.map(chain => isEvmChain(chain) ? chain.id : chain.id); + return allChains.map(getChainId); }); - // Effect to save chain selection to localStorage when it changes + // Wallet states for adapter factory + const walletStates: WalletStates = useMemo(() => ({ + evm: { account, privateKey, isReady }, + solana: { publicKey: solanaPublicKey, keypair: solanaKeypair, isReady: solanaReady }, + fuel: { wallet: fuelWallet, isReady: fuelReady }, + aptos: { account: aptosAccount, address: aptosAddress, isReady: aptosReady }, + soon: { publicKey: soonPublicKey, keypair: soonKeypair }, + starknet: { starknetprivateKey, starknetaccount, starknetisReady } + }), [account, privateKey, isReady, solanaPublicKey, solanaKeypair, solanaReady, + fuelWallet, fuelReady, aptosAccount, aptosAddress, aptosReady, + soonPublicKey, soonKeypair, starknetprivateKey, starknetaccount, starknetisReady]); + + // localStorage effects useEffect(() => { if (typeof window !== 'undefined' && selectedChains.length > 0) { localStorage.setItem(LOCAL_STORAGE_SELECTED_CHAINS, JSON.stringify(selectedChains)); } }, [selectedChains]); - // Effect to save transaction count to localStorage when it changes useEffect(() => { if (typeof window !== 'undefined') { localStorage.setItem(LOCAL_STORAGE_TX_COUNT, transactionCount.toString()); } }, [transactionCount]); - // Effect to save layer filter to localStorage when it changes useEffect(() => { if (typeof window !== 'undefined') { localStorage.setItem(LOCAL_STORAGE_LAYER_FILTER, layerFilter); } }, [layerFilter]); - // Effect to save network filter to localStorage when it changes useEffect(() => { if (typeof window !== 'undefined') { localStorage.setItem(LOCAL_STORAGE_NETWORK_FILTER, networkFilter); } }, [networkFilter]); - // Get filtered chains based on layer filter and network filter + // Get filtered chains based on layer and network filters const getFilteredChains = useCallback(() => { return allChains.filter(chain => { // Layer filter if (layerFilter !== 'Both') { - if (isEvmChain(chain)) { - if (chain.layer !== layerFilter) return false; - } else if (isFuelChain(chain)) { - if (chain.layer !== layerFilter) return false; - } else if (isAptosChain(chain)) { + if ('layer' in chain) { if (chain.layer !== layerFilter) return false; - } else if (isSoonChain(chain)) { - // For SOON chains, we'll consider them as L2 for filtering purposes (SVM rollup) - if (layerFilter !== 'L2') return false; - } else if (isStarknetChain(chain)) { - if (layerFilter !== 'L2') return false; } else { - // For Solana chains, we'll consider them as L1 for filtering purposes - if (layerFilter !== 'L1') return false; + // For non-EVM chains, apply layer logic as in original + const chainId = getChainId(chain); + if (typeof chainId === 'string') { + if (chainId.startsWith('soon-') || chainId.includes('starknet')) { + if (layerFilter !== 'L2') return false; + } else { + // Solana chains considered L1 + if (layerFilter !== 'L1') return false; + } + } } } - // Network filter (mainnet vs testnet) - no "Both" option - if (isEvmChain(chain)) { + // Network filter + if ('testnet' in chain) { const isTestnet = chain.testnet; if (networkFilter === 'Testnet' && !isTestnet) return false; if (networkFilter === 'Mainnet' && isTestnet) return false; - } else if (isFuelChain(chain)) { - const isTestnet = chain.testnet; - if (networkFilter === 'Testnet' && !isTestnet) return false; - if (networkFilter === 'Mainnet' && isTestnet) return false; - } else if (isAptosChain(chain)) { - const isTestnet = chain.testnet; - if (networkFilter === 'Testnet' && !isTestnet) return false; - if (networkFilter === 'Mainnet' && isTestnet) return false; - } else if (isSoonChain(chain)) { - const isTestnet = chain.testnet; - if (networkFilter === 'Testnet' && !isTestnet) return false; - if (networkFilter === 'Mainnet' && isTestnet) return false; - }else if (isStarknetChain(chain)) { - const isTestnet = chain.testnet; - if (networkFilter === 'Testnet' && !isTestnet) return false; - if (networkFilter === 'Mainnet' && isTestnet) return false; - } - else { - // For Solana chains, check if it's mainnet or testnet based on the id - const isMainnet = chain.id === 'solana-mainnet'; - if (networkFilter === 'Mainnet' && !isMainnet) return false; - if (networkFilter === 'Testnet' && isMainnet) return false; + } else { + // For Solana chains + const chainId = getChainId(chain); + if (typeof chainId === 'string') { + const isMainnet = chainId === 'solana-mainnet'; + if (networkFilter === 'Mainnet' && !isMainnet) return false; + if (networkFilter === 'Testnet' && isMainnet) return false; + } } return true; }); }, [layerFilter, networkFilter]); - // Define checkBalances before using it in useEffect + // Create adapters for all chains + const allAdapters = useMemo(() => { + try { + return ChainAdapterFactory.createMultiple(allChains, walletStates); + } catch (error) { + console.error('Error creating adapters:', error); + return []; + } + }, [walletStates]); + + // Check balances using adapters const checkBalances = useCallback(async () => { - // Only require EVM wallet to be ready - other wallets can load independently if (!account) { setIsLoadingBalances(false); return; @@ -306,253 +234,22 @@ export function useChainRace() { setIsLoadingBalances(true); try { - // Check balances for all chains regardless of selection - const activeChains = allChains; - - const balancePromises = activeChains.map(async (chain) => { - // Function to attempt a balance check with retries - const attemptBalanceCheck = async (retryCount = 0, maxRetries = 3): Promise<{ - chainId: number | string, - balance: bigint, - hasBalance: boolean, - error?: string - }> => { - try { - let balance: bigint; - const chainId = isEvmChain(chain) ? chain.id : chain.id; - - if (isEvmChain(chain)) { - // EVM chain balance check - const client = createPublicClient({ - chain, - transport: http(), - }); - - balance = await client.getBalance({ address: account.address }); - // Reduced balance threshold for testing (0.001 tokens instead of 0.01) - const hasBalance = balance > BigInt(1e14); - - return { - chainId, - balance, - hasBalance, - }; - } - else if (isSolanaChain(chain)) { - // Skip if Solana wallet not ready yet - if (!solanaReady || !solanaPublicKey) { - return { - chainId, - balance: BigInt(0), - hasBalance: false, - error: "Solana wallet still loading..." - }; - } - - // Solana chain balance check with fallback endpoints - const fallbackEndpoints = getSolanaFallbackEndpoints(chain); - - let lastError; - for (const endpoint of fallbackEndpoints) { - try { - const connection = new Connection(endpoint, chain.commitment); - const lamports = await connection.getBalance(solanaPublicKey, chain.commitment); - - // Convert lamports to bigint for consistency with EVM - balance = BigInt(lamports); - // Minimum balance threshold: 0.001 SOL (1,000,000 lamports) - const hasBalance = balance > BigInt(1_000_000); - - return { - chainId, - balance, - hasBalance, - }; - } catch (endpointError) { - console.warn(`Solana RPC ${endpoint} failed for ${chain.id}:`, endpointError); - lastError = endpointError; - continue; - } - } - - // If all endpoints failed, throw the last error - throw lastError || new Error(`All Solana RPC endpoints failed for ${chain.id}`); - } - else if (isSoonChain(chain)) { - // Skip if SOON wallet not ready yet - if (!soonPublicKey) { - return { - chainId, - balance: BigInt(0), - hasBalance: false, - error: "SOON wallet still loading..." - }; - } - - try { - const connection = new Connection(chain.endpoint, chain.commitment); - const lamports = await connection.getBalance(soonPublicKey, chain.commitment); - - // Convert lamports to bigint for consistency with EVM - balance = BigInt(lamports); - // Minimum balance threshold: 0.001 ETH (1,000,000 lamports for SOON) - const hasBalance = balance > BigInt(1_000_000); - - return { - chainId, - balance, - hasBalance, - }; - } catch (error) { - console.error(`SOON balance check failed for ${chain.id}:`, error); - throw error; - } - } else if (isFuelChain(chain)) { - // Skip if Fuel wallet not ready yet - if (!fuelReady || !fuelWallet) { - return { - chainId, - balance: BigInt(0), - hasBalance: false, - error: "Fuel wallet still loading..." - }; - } - - // Fuel balance check - const provider = new Provider(chain.rpcUrls.public.http[0]); - fuelWallet.connect(provider); - const fuelBalance = await fuelWallet.getBalance(); - // Convert BN to bigint for consistency - balance = BigInt(fuelBalance.toString()); - // Minimum balance threshold: 0.001 ETH (1e6 since Fuel uses 9 decimals) - const hasBalance = balance > BigInt(1e6); - - return { - chainId, - balance, - hasBalance, - }; - } - else if (isStarknetChain(chain)) { - // Starknet balance check - if (!starknetaccount?.address) { - return { - chainId, - balance: BigInt(0), - hasBalance: false, - error: "No Starknet account address available" - }; - } - - try { - const provider = new RpcProvider({ nodeUrl: chain.endpoint }); - const erc20Contract = new Contract(Erc20Abi, STRK_ADDRESS, provider); - - // Get the balance using the correct method - const starknetBalance = await erc20Contract.balance_of(starknetaccount.address); - - - // Convert BN to bigint for consistency - balance = BigInt(starknetBalance.toString()); - - // Minimum balance threshold: 0.02 STRK (20000000000000000 since STRK uses 18 decimals) - const hasBalance = balance > BigInt("20000000000000000"); - - return { - chainId, - balance, - hasBalance, - }; - } catch (error) { - console.error('🔍 [Chain Derby] Starknet balance check failed:', error); - return { - chainId, - balance: BigInt(0), - hasBalance: false, - error: (error as Error).message - }; - } - } - else if (isAptosChain(chain)) { - // Skip if Aptos wallet not ready yet - if (!aptosReady || !aptosAccount) { - return { - chainId, - balance: BigInt(0), - hasBalance: false, - error: "Aptos wallet still loading..." - }; - } - - // Aptos balance check - const config = new AptosConfig({ - network: chain.network as Network, - fullnode: chain.rpcUrl, - }); - const aptos = new Aptos(config); - - const balance = BigInt(await aptos.getAccountAPTAmount({accountAddress: aptosAccount.accountAddress})); - - // Minimum balance threshold: 0.001 APT (100,000 octas since APT uses 8 decimals) - const hasBalance = balance > BigInt(100_000); - - return { - chainId, - balance, - hasBalance, - }; - } else { - throw new Error(`Unsupported chain type: ${chainId}`); - } - } catch (error) { - console.error(`Failed to get balance for chain ${isEvmChain(chain) ? chain.id : chain.id} (attempt ${retryCount + 1}/${maxRetries + 1}):`, error); - - // Retry logic - if (retryCount < maxRetries) { - // Exponential backoff: 1s, 2s, 4s, etc. - const backoffTime = 1000 * Math.pow(2, retryCount); - await new Promise(resolve => setTimeout(resolve, backoffTime)); - return attemptBalanceCheck(retryCount + 1, maxRetries); - } - - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - return { - chainId: isEvmChain(chain) ? chain.id : chain.id, - balance: BigInt(0), - hasBalance: false, - error: errorMessage, - }; - } - }; - - // Wrap in a timeout to ensure the promise resolves eventually - const timeoutPromise = new Promise<{ - chainId: number | string, - balance: bigint, - hasBalance: boolean, - error?: string - }>((_, reject) => { - setTimeout(() => reject(new Error(`RPC request timed out for ${chain.name}`)), 30000); - }); - - // Race the balance check with the timeout - return Promise.race([attemptBalanceCheck(), timeoutPromise]) - .catch(error => { - console.error(`Ultimate failure checking balance for ${chain.name}:`, error); - return { - chainId: isEvmChain(chain) ? chain.id : chain.id, - balance: BigInt(0), - hasBalance: false, - error: error instanceof Error - ? `Request failed: ${error.message}` - : "Unknown error checking balance", - }; - }); + const balancePromises = allAdapters.map(async (adapter) => { + try { + return await adapter.checkBalance(); + } catch (error) { + console.error(`Failed to check balance for ${adapter.name}:`, error); + return { + chainId: adapter.chainId, + balance: BigInt(0), + hasBalance: false, + error: error instanceof Error ? error.message : "Unknown error" + }; + } }); const newBalances = await Promise.all(balancePromises); - // Don't update state if component unmounted during the operation if (!account) { setIsLoadingBalances(false); return; @@ -560,29 +257,19 @@ export function useChainRace() { setBalances(newBalances); - // Only consider selected chains for determining if all are funded - const selectedBalances = newBalances.filter(b => - selectedChains.includes(b.chainId) - ); - - const fundedChains = newBalances - .filter(b => b.hasBalance) - .map(b => b.chainId); - + // Update status based on balances + const selectedBalances = newBalances.filter(b => selectedChains.includes(b.chainId)); const allSelectedFunded = selectedBalances.every(b => b.hasBalance); if (status !== "racing" && status !== "finished") { if (allSelectedFunded && selectedBalances.length > 0) { setStatus("ready"); } else { - // Only remove unfunded chains from selection, don't auto-select all funded chains - const fundedSelectedChains = selectedChains.filter(chainId => - fundedChains.includes(chainId) - ); + const fundedChains = newBalances.filter(b => b.hasBalance).map(b => b.chainId); + const fundedSelectedChains = selectedChains.filter(chainId => fundedChains.includes(chainId)); if (fundedSelectedChains.length > 0) { setStatus("ready"); - // Only update selection if some selected chains became unfunded if (fundedSelectedChains.length !== selectedChains.length) { setSelectedChains(fundedSelectedChains); } @@ -599,17 +286,12 @@ export function useChainRace() { } finally { setIsLoadingBalances(false); } - }, [account, solanaPublicKey, solanaReady, fuelWallet, fuelReady, aptosAccount, aptosReady, soonPublicKey, starknetaccount, status, selectedChains, allChains]); + }, [account, allAdapters, selectedChains, status]); // Effect to check initial balances useEffect(() => { const checkInitialBalances = async () => { - if (isLoadingBalances || status === "racing" || status === "finished") { - return; - } - - // Only require EVM wallet to be ready - other wallets can load independently - if (!account) { + if (isLoadingBalances || status === "racing" || status === "finished" || !account) { return; } @@ -622,1555 +304,132 @@ export function useChainRace() { }; checkInitialBalances(); - }, [status, checkBalances, account, solanaReady, solanaPublicKey, fuelReady, fuelWallet, aptosReady, aptosAccount, soonPublicKey, starknetaccount]); - - - - // Effect to save race results when race finishes - useEffect(() => { - const saveResults = async () => { - if (status === 'finished' && results.length > 0 && account) { - try { - const isDevelopment = process.env.NODE_ENV === 'development'; - - if (isDevelopment) { - console.log('🏁 [Chain Derby] Race finished! Preparing to save results...'); - console.log('🔍 [Chain Derby] Results data:', results); - console.log('👤 [Chain Derby] Account:', account?.address); - console.log('🔢 [Chain Derby] Transaction count:', transactionCount); - } + }, [status, checkBalances, account]); - const geo = await getGeo(); - - if (isDevelopment) { - console.log('🌍 [Chain Derby] Geo location:', geo); - } - - // Convert results to the API payload format - const chainResults: ChainResultPayload[] = results.map(result => { - // Convert string chain IDs to numeric IDs for API compatibility - let numericChainId: number; - if (typeof result.chainId === 'string') { - if (result.chainId.includes('solana')) { - numericChainId = 999999; // Solana chains get ID 999999 - } else if (result.chainId.includes('aptos-testnet')) { - numericChainId = 999998; // Aptos testnet gets ID 999998 - } else if (result.chainId.includes('aptos-mainnet')) { - numericChainId = 999997; // Aptos mainnet gets ID 999997 - } else { - numericChainId = 999996; // Other string IDs get 999996 - } - } else { - numericChainId = result.chainId; - } - - return { - chainId: numericChainId, - chainName: result.name, - txLatencies: result.txLatencies, - averageLatency: result.averageLatency || 0, - totalLatency: result.totalLatency || 0, - status: result.status, - position: result.position, - }; - }); - - if (isDevelopment) { - console.log('⛓️ [Chain Derby] Processed chain results:', chainResults); - } - - const payload: RaceSessionPayload = { - title: `Chain Derby Race - ${new Date().toISOString()}`, - walletAddress: account.address, - transactionCount, - status: 'completed', - city: geo.city, - region: geo.region, - country: geo.country, - results: chainResults, - }; - - if (isDevelopment) { - console.log('📋 [Chain Derby] Final payload prepared:', payload); - console.log('🚀 [Chain Derby] Initiating API call...'); - } - - await saveRaceResults(payload); - - if (isDevelopment) { - console.log('🎉 [Chain Derby] Race results saved successfully!'); - } - } catch (error) { - const isDevelopment = process.env.NODE_ENV === 'development'; - - if (isDevelopment) { - console.error('❌ [Chain Derby] Failed to save race results:', error); - } - // Silently handle API failures - don't impact user experience - } - } - }; - - // Don't await this - let it run in background without blocking - saveResults(); - }, [status, results, account, transactionCount]); - - // Create a wallet client - defined at hook level to avoid ESLint warnings - const createClient = (chain: Chain) => { - if (!account) { - throw new Error("Cannot create wallet client: account is null"); - } - - return createWalletClient({ - account, - chain, - transport: http(), - }); - }; - - // Start the race across selected chains + // Start the race using adapters const startRace = async () => { if (!account || !privateKey || status !== "ready") return; setStatus("racing"); - // Filter chains based on layer filter AND selection (support both EVM and Solana) + // Get filtered and selected chains const filteredChains = getFilteredChains(); - const activeChains = filteredChains.filter(chain => - selectedChains.includes(isEvmChain(chain) ? chain.id : chain.id) + const activeChains = filteredChains.filter(chain => + selectedChains.includes(getChainId(chain)) ); - // Pre-fetch all chain data needed for transactions - const chainData = new Map(); - - try { - // Fetch chain data in parallel for selected chains - const chainDataPromises = activeChains.map(async (chain) => { - const chainId = isEvmChain(chain) ? chain.id : chain.id; - - try { - if (isEvmChain(chain)) { - // EVM chain data fetching - const client = createPublicClient({ - chain, - transport: http(), - }); - - // Run all required queries in parallel - const [nonce, feeData, blockData] = await Promise.all([ - // Get current nonce - client.getTransactionCount({ - address: account.address, - }), - - // Get current fee data and double it for better confirmation chances - client.getGasPrice().then(gasPrice => { - const doubledGasPrice = gasPrice * BigInt(3); - return doubledGasPrice; - }).catch(() => { - // Fallback gas prices based on known chain requirements - const fallbackGasPrice = BigInt( - chain.id === 10143 ? 60000000000 : // Monad has higher gas requirements - chain.id === 8453 ? 2000000000 : // Base mainnet - chain.id === 17180 ? 1500000000 : // Sonic - 1000000000 // Default fallback (1 gwei) - ); - return fallbackGasPrice; - }), - - // Get latest block to ensure we have chain state - client.getBlock().catch(() => null) - ]); - - // Create wallet client for transaction signing - const walletClient = createClient(chain); - - // Pre-sign all transactions - const signedTransactions = []; - - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Use Sepolia-like parameters for Monad since it's finicky - const txParams = { - to: account.address, - value: BigInt(0), - gas: 21000n, - gasPrice: feeData, - nonce: nonce + txIndex, - chainId: chain.id, - data: '0x' as const, // Use const assertion for hex string - }; - - const signedTx = await walletClient.signTransaction(txParams); - - if (!signedTx) { - throw new Error("Signing transaction returned null"); - } - - signedTransactions.push(signedTx); - } catch (signError) { - console.error(`Error signing tx #${txIndex} for ${chain.name}:`, signError); - // Push a placeholder so the array length still matches txIndex - signedTransactions.push(null); - } - } - - return { - chainId, - nonce, - gasPrice: feeData, - feeData, - blockData, - signedTransactions - }; - } else if (isSolanaChain(chain)) { - // Skip if Solana wallet not ready yet - if (!solanaKeypair) { - throw new Error(`Solana wallet not ready for ${chain.id}`); - } - - // Solana chain data fetching - try fallback endpoints to find working RPC - const fallbackEndpoints = getSolanaFallbackEndpoints(chain); - - let workingConnection = null; - for (const endpoint of fallbackEndpoints) { - try { - const connection = new Connection(endpoint, chain.commitment); - // Test the connection by getting latest blockhash - await connection.getLatestBlockhash(chain.commitment); - workingConnection = connection; - console.log(`Using Solana RPC ${endpoint} for ${chain.id}`); - break; - } catch (endpointError) { - console.warn(`Solana RPC ${endpoint} failed for ${chain.id} during setup:`, endpointError); - continue; - } - } - - if (!workingConnection) { - throw new Error(`All Solana RPC endpoints failed for ${chain.id} during setup`); - } - - // Pre-sign all Solana transactions - const signedTransactions = []; - - try { - // Get the latest blockhash for all transactions - const { blockhash } = await workingConnection.getLatestBlockhash(chain.commitment); - - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Create transaction with unique transfer amount to avoid duplicate signatures - const transaction = new Transaction(); - transaction.feePayer = solanaKeypair.publicKey; - transaction.recentBlockhash = blockhash; - transaction.add( - SystemProgram.transfer({ - fromPubkey: solanaKeypair.publicKey, - toPubkey: solanaKeypair.publicKey, - lamports: txIndex + 1, // Use different amounts to make transactions unique (1, 2, 3, etc.) - }) - ); - - // Sign the transaction - transaction.sign(solanaKeypair); - - // Serialize the signed transaction - const serializedTx = transaction.serialize(); - signedTransactions.push(serializedTx); - } catch (signError) { - console.error(`Error signing Solana tx #${txIndex} for ${chain.id}:`, signError); - signedTransactions.push(null); - } - } - } catch (blockhashError) { - console.error(`Error getting blockhash for Solana ${chain.id}:`, blockhashError); - } - - return { - chainId, - nonce: 0, // Not applicable for Solana - connection: workingConnection, - signedTransactions - }; - } else if (isSoonChain(chain)) { - // SOON chain data fetching - similar to Solana since it's SVM-based - if (!soonKeypair) { - throw new Error("SOON wallet not initialized"); - } - - try { - const connection = new Connection(chain.endpoint, chain.commitment); - // Test the connection by getting latest blockhash - await connection.getLatestBlockhash(chain.commitment); - console.log(`Using SOON RPC ${chain.endpoint} for ${chain.id}`); - - // Pre-sign all SOON transactions - const signedTransactions = []; - - try { - // Get the latest blockhash for all transactions - const { blockhash } = await connection.getLatestBlockhash(chain.commitment); - - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Create transaction with unique transfer amount to avoid duplicate signatures - const transaction = new Transaction(); - transaction.feePayer = soonKeypair.publicKey; - transaction.recentBlockhash = blockhash; - transaction.add( - SystemProgram.transfer({ - fromPubkey: soonKeypair.publicKey, - toPubkey: soonKeypair.publicKey, - lamports: txIndex + 1, // Use different amounts to make transactions unique - }) - ); - - // Sign the transaction - transaction.sign(soonKeypair); - - // Serialize the signed transaction - const serializedTx = transaction.serialize(); - signedTransactions.push(serializedTx); - } catch (signError) { - console.error(`Error signing SOON tx #${txIndex} for ${chain.id}:`, signError); - signedTransactions.push(null); - } - } - } catch (blockhashError) { - console.error(`Error getting blockhash for SOON ${chain.id}:`, blockhashError); - } - - return { - chainId, - nonce: 0, // Not applicable for SOON - connection, - signedTransactions - }; - } catch (error) { - throw new Error(`SOON RPC failed for ${chain.id} during setup: ${error instanceof Error ? error.message : String(error)}`); - } - } - else if (isStarknetChain(chain)) { - try { - const provider = new RpcProvider({ nodeUrl: chain.endpoint }); - const account = new Account( - provider, - starknetaccount?.address ?? "", - starknetprivateKey ?? "" - ); - return { - chainId, - nonce: Number(await account.getNonce()), - starknet: { - provider, - account - } - }; - } catch (error) { - throw new Error(`Starknet RPC failed for ${chain.id} during setup: ${error instanceof Error ? error.message : String(error)}`); - } - } - - else if (isFuelChain(chain)) { - // Skip if Fuel wallet not ready yet - if (!fuelWallet) { - throw new Error(`Fuel wallet not ready for ${chain.id}`); - } - - // Fuel chain data fetching - const provider = new Provider(chain.rpcUrls.public.http[0]); - const wallet = fuelWallet as WalletUnlocked; - wallet.connect(provider); - const baseAssetId = await provider.getBaseAssetId(); - const walletCoins = await wallet.getCoins(baseAssetId); - - // Find UTXOs with sufficient balance (greater than 10000) - const coins = walletCoins.coins as Coin[]; - const validUtxos = coins.filter(coin => { - const amount = coin.amount.toNumber(); // Convert BN to number - return amount > 10000; - }); - - if (validUtxos.length === 0) { - throw new Error("No UTXOs with sufficient balance found"); - } - - // Pre-sign only the first transaction - const signedTransactions = []; - try { - // Create transaction request with selected UTXO - const initialScriptRequest = new ScriptTransactionRequest({ - script: "0x" - }); - initialScriptRequest.maxFee = bn(100); - initialScriptRequest.addCoinInput(validUtxos[0]); - const initialSignedTx = await wallet.populateTransactionWitnessesSignature(initialScriptRequest); - signedTransactions.push(initialSignedTx); - } catch (signError) { - console.error(`Error signing first tx for Fuel chain:`, signError); - signedTransactions.push(null); - } - - return { - chainId, - nonce: 0, - wallet, - signedTransactions, - }; - } else if (isAptosChain(chain)) { - // Skip if Aptos wallet not ready yet - if (!aptosAccount) { - throw new Error(`Aptos wallet not ready for ${chain.id}`); - } - - // Aptos chain data fetching - const config = new AptosConfig({ - network: (chain as AptosChainConfig).network as Network, - fullnode: (chain as AptosChainConfig).rpcUrl, - }); - const aptos = new Aptos(config); - - // Fetch sequence number for the account - const accountData = await aptos.getAccountInfo({ accountAddress: aptosAccount.accountAddress }); - const sequenceNumber = BigInt(accountData.sequence_number); - - // Pre-sign all transactions - const buildAndSignTransaction = async (txIndex: number, aptosSeqNo: bigint) => { - const transaction = await aptos.transaction.build.simple({ - sender: aptosAccount.accountAddress, - data: { - function: "0x1::aptos_account::transfer", - functionArguments: [aptosAccount.accountAddress, new U64(0)], // Transfer 0 APT to self - abi: { - // ABI skips call to check arguments - signers: 1, - typeParameters: [], - parameters: [new TypeTagAddress(), new TypeTagU64()] - } - }, - options: { - accountSequenceNumber: aptosSeqNo! + BigInt(txIndex), - gasUnitPrice: 100, // Default gas price, no reason to estimate - maxGasAmount: 1000, // Set a max gas, no need for it to be too high - } - }) - return { - transaction, - senderAuthenticator: aptos.transaction.sign({ - signer: aptosAccount, - transaction, - }) - } - } - const signedTransactionPromises = []; - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - signedTransactionPromises.push(buildAndSignTransaction(txIndex, sequenceNumber)); - } - - const signedTransactions = await Promise.all(signedTransactionPromises) - - // Store the aptos client for transaction submission during race - return { - chainId, - nonce: 0, - aptos, - signedTransactions, - }; - } else { - throw new Error(`Unsupported chain type: ${chainId}`); - } - } catch (fetchError) { - console.error(`Failed to get chain data for ${chain.name}:`, fetchError); - - if (isEvmChain(chain)) { - // Use specific fallback gas prices based on chain - const fallbackGasPrice = BigInt( - chain.id === 10143 ? 60000000000 : // Monad has higher gas requirements - chain.id === 8453 ? 2000000000 : // Base mainnet - chain.id === 17180 ? 1500000000 : // Sonic - chain.id === 6342 ? 3000000000 : // MegaETH - 1000000000 // Default fallback (1 gwei) - ); - - return { - chainId, - nonce: 0, - gasPrice: fallbackGasPrice, - signedTransactions: [], - }; - } else { - // Solana fallback - return { - chainId, - nonce: 0, - signedTransactions: [], - }; - } - } - }); - - // Store fetched data in the Map - const results = await Promise.all(chainDataPromises); - results.forEach((data) => { - if (data && data.chainId !== undefined) { - chainData.set(data.chainId, data); - } - }); - } catch (error) { - console.error("Error prefetching chain data:", error); - } + // Create adapters for active chains + const activeAdapters = activeChains.map(chain => + ChainAdapterFactory.create(chain, walletStates) + ); - // Reset results for active chains only - const initialResults = activeChains.map(chain => ({ - chainId: isEvmChain(chain) ? chain.id : chain.id, - name: chain.name, - color: chain.color, - logo: chain.logo, // Add logo path from the chain config + // Initialize results + const initialResults = activeAdapters.map(adapter => ({ + chainId: adapter.chainId, + name: adapter.name, + color: adapter.color, + logo: adapter.logo, status: "pending" as const, txCompleted: 0, txTotal: transactionCount, - txLatencies: [], // Empty array to store individual transaction latencies + txLatencies: [], })); setResults(initialResults); - // Run transactions in parallel for each active chain - activeChains.forEach(async (chain) => { - const chainId = isEvmChain(chain) ? chain.id : chain.id; - + // Run races in parallel for each adapter + activeAdapters.forEach(async (adapter) => { try { - // Update status to racing for this chain + // Update status to racing setResults(prev => - prev.map(r => - r.chainId === chainId - ? { ...r, status: "racing" } - : r - ) + prev.map(r => r.chainId === adapter.chainId ? { ...r, status: "racing" } : r) ); - if (isEvmChain(chain)) { - // EVM chain transaction processing - const publicClient = chain.id !== 11155931 ? - createPublicClient({ - chain, - transport: http(), - }) : null; - - // Run the specified number of transactions - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Skip if chain already had an error - const currentState = results.find(r => r.chainId === chainId); - if (currentState?.status === "error") { - break; - } - - let txHash: Hex; - let txLatency = 0; // Initialize txLatency to avoid reference error - const txStartTime = Date.now(); // Start time for this individual transaction - - // Get pre-fetched chain data including pre-signed transactions - // Using more specific fallback gas prices if chain data isn't available - const fallbackGasPrice = BigInt( - chain.id === 10143 ? 60000000000 : // Monad has higher gas requirements - chain.id === 8453 ? 2000000000 : // Base mainnet - chain.id === 6342 ? 3000000000 : // MegaETH - chain.id === 17180 ? 1500000000 : // Sonic - 1000000000 // Default fallback (1 gwei) - ); - - const currentChainData = chainData.get(chainId) || { - nonce: 0, - gasPrice: fallbackGasPrice, - signedTransactions: [] - }; - - // Get the pre-signed transaction for this index - const hasPreSignedTx = currentChainData.signedTransactions && - txIndex < currentChainData.signedTransactions.length && - currentChainData.signedTransactions[txIndex] !== null; - - // Use pre-signed transaction if available and not null - const signedTransaction = hasPreSignedTx - ? currentChainData.signedTransactions![txIndex] - : null; - - - if (chain.id === 11155931) { - // For RISE testnet, use the sync client with decorator pattern - const RISESyncClient = createPublicClient({ - chain, - transport: http(), - }).extend(syncActions); - - // Use pre-signed transaction if available, otherwise sign now - const txToSend = signedTransaction; - - - // Check if we have a valid transaction - if (!txToSend || typeof txToSend !== 'string') { - throw new Error(`Invalid transaction format for RISE tx #${txIndex}`); - } - - // Send the transaction and get receipt in one call - const receipt = await RISESyncClient.sendRawTransactionSync({ - serializedTransaction: txToSend as `0x${string}` - }); - - // Verify receipt - if (!receipt || !receipt.transactionHash) { - throw new Error(`RISE sync transaction sent but no receipt returned for tx #${txIndex}`); - } - txHash = receipt.transactionHash; - // Calculate transaction latency for RISE - const txEndTime = Date.now(); - txLatency = txEndTime - txStartTime; // Using outer txLatency variable here - } else if (chain.id === 6342) { - // For MegaETH testnet, use the custom realtime_sendRawTransaction method - - // Use pre-signed transaction if available, otherwise sign now - const txToSend = signedTransaction; - - // Check if we have a valid transaction - if (!txToSend || typeof txToSend !== 'string') { - throw new Error(`Invalid transaction format for MegaETH tx #${txIndex}`); - } - - // Explicitly verify the transaction is a valid string before sending - if (typeof txToSend !== 'string' || !txToSend.startsWith('0x')) { - throw new Error(`Invalid transaction format for MegaETH tx #${txIndex}: ${typeof txToSend}`); - } - - // Create a custom request to use the standard send transaction method - // MegaETH devs intended realtime_sendRawTransaction but it's not a standard method - const receipt = await publicClient!.request({ - // @ts-expect-error - MegaETH custom method not in standard types - method: 'realtime_sendRawTransaction', - params: [txToSend as `0x${string}`] - }) as TransactionReceipt | null; - - // The result is the transaction hash directly - if (!receipt) { - throw new Error(`MegaETH transaction sent but no hash returned for tx #${txIndex}`); - } - - txHash = receipt.transactionHash as Hex; + // Prepare transactions + const preparedTransactions = await adapter.prepareTransactions(transactionCount); - // Calculate transaction latency for MegaETH - const txEndTime = Date.now(); - txLatency = txEndTime - txStartTime; - } else { + // Execute transactions sequentially for each chain + for (let i = 0; i < transactionCount; i++) { + try { + // Check if chain already errored + const currentState = results.find(r => r.chainId === adapter.chainId); + if (currentState?.status === "error") break; - // Use pre-signed transaction if available, otherwise sign now - const txToSend = signedTransaction; + const preparedTx = preparedTransactions[i]; + if (!preparedTx) continue; - // Critical null safety check - if (!txToSend) { - throw new Error(`No transaction to send for ${chain.name} tx #${txIndex}`); - } + // Execute transaction + const result = await adapter.executeTransaction(preparedTx); + if (!result.success) { + throw new Error(result.error || "Transaction failed"); + } - // Explicitly verify the transaction is a valid string before sending - if (typeof txToSend !== 'string' || !txToSend.startsWith('0x')) { - throw new Error(`Invalid transaction format for ${chain.name} tx #${txIndex}: ${typeof txToSend}`); - } + // Update results + setResults(prev => { + const updatedResults = prev.map(r => { + if (r.chainId === adapter.chainId) { + const newLatencies = [...r.txLatencies, result.latency]; + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; - // Normal path for non-Monad chains - // Send the raw transaction - wagmi v2 changed the API - txHash = await publicClient!.sendRawTransaction({ - serializedTransaction: txToSend as `0x${string}` - }); + const totalLatency = newLatencies.reduce((sum, val) => sum + val, 0); + const averageLatency = Math.round(totalLatency / newLatencies.length); - if (!txHash) { - throw new Error(`Transaction sent but no hash returned for ${chain.name} tx #${txIndex}`); + return { + ...r, + txHash: result.hash || r.txHash, + signature: result.signature || r.signature, + txCompleted, + status: allTxCompleted ? "success" as const : "racing" as const, + txLatencies: newLatencies, + averageLatency, + totalLatency + }; } + return r; + }); - } - - // Update result with transaction hash - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { ...r, txHash } // Just store the latest hash - : r - ) - ); - - // For non-RISE and non-MegaETH chains, we need to wait for confirmation - if (chain.id !== 11155931 && chain.id !== 6342) { - // Wait for transaction to be confirmed - await publicClient!.waitForTransactionReceipt({ - pollingInterval: 50, // 50ms to avoid rate limits - retryDelay: 1, // 1ms - hash: txHash, - timeout: 60_000, // 60 seconds timeout - }); + // Update positions + const finishedResults = updatedResults + .filter(r => r.status === "success") + .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); - // Calculate total transaction latency from start to confirmation - const txEndTime = Date.now(); - txLatency = txEndTime - txStartTime; - } - - // Transaction confirmed, update completed count and track latencies for all chains - setResults((prev) => { - const updatedResults = prev.map(r => { - if (r.chainId === chainId) { - // Add this transaction's latency to the array - const newLatencies = [...r.txLatencies, txLatency]; - - const txCompleted = r.txCompleted + 1; - const allTxCompleted = txCompleted >= transactionCount; - - // Calculate total and average latency if we have latencies - const totalLatency = newLatencies.length > 0 - ? newLatencies.reduce((sum, val) => sum + val, 0) - : undefined; - - const averageLatency = totalLatency !== undefined - ? Math.round(totalLatency / newLatencies.length) - : undefined; - - - // Ensure status is one of the allowed values from RaceResult.status type - const newStatus: "pending" | "racing" | "success" | "error" = - allTxCompleted ? "success" : "racing"; - - return { - ...r, - txCompleted, - status: newStatus, - txLatencies: newLatencies, - averageLatency, - totalLatency - }; + finishedResults.forEach((result, idx) => { + const position = idx + 1; + updatedResults.forEach((r, i) => { + if (r.chainId === result.chainId) { + updatedResults[i] = { ...r, position }; } - return r; - }); - - // Only determine rankings when chains finish all transactions - const finishedResults = updatedResults - .filter(r => r.status === "success") - .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); - - // Assign positions to finished results - finishedResults.forEach((result, idx) => { - const position = idx + 1; - updatedResults.forEach((r, i) => { - if (r.chainId === result.chainId) { - updatedResults[i] = { ...r, position }; - } - }); }); - - return updatedResults; }); - } catch (error) { - console.error(`Race error for chain ${chain.id}, tx #${txIndex}:`, error); - - // Provide a more user-friendly error message - let errorMessage = "Transaction failed"; - - if (error instanceof Error) { - // Extract the most useful part of the error message - const fullMessage = error.message; - - if (fullMessage.includes("Invalid params")) { - errorMessage = "Invalid transaction parameters. Chain may require specific gas settings."; - } else if (fullMessage.includes("insufficient funds")) { - errorMessage = "Insufficient funds for gas + value."; - } else if (fullMessage.includes("nonce too low")) { - errorMessage = "Transaction nonce issue. Try again with a new wallet."; - } else if (fullMessage.includes("timeout")) { - errorMessage = "Network timeout. Chain may be congested."; - } else { - // Use the first line of the error message if available - const firstLine = fullMessage.split('\n')[0]; - errorMessage = firstLine || fullMessage; - } - } - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { - ...r, - status: "error" as const, - error: errorMessage - } - : r - ) - ); - break; // Stop sending transactions for this chain if there's an error - } - } + return updatedResults; + }); - } else if (isSolanaChain(chain)) { - // Skip if Solana wallet not ready yet - if (!solanaKeypair) { - console.error(`Solana wallet not ready for ${chainId}`); + } catch (error) { + console.error(`Race error for ${adapter.name}, tx #${i}:`, error); + setResults(prev => prev.map(r => - r.chainId === chainId + r.chainId === adapter.chainId ? { ...r, status: "error" as const, - error: "Solana wallet not ready" + error: error instanceof Error ? error.message : "Transaction failed" } : r ) ); - return; - } - - // Solana chain transaction processing - const currentChainData = chainData.get(chainId); - - if (!currentChainData || !currentChainData.connection) { - console.error(`No connection data for Solana chain ${chainId}`); - return; - } - - // Run the specified number of transactions - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Skip if chain already had an error - const currentState = results.find(r => r.chainId === chainId); - if (currentState?.status === "error") { - break; - } - - let txLatency = 0; - let signature: string; - const txStartTime = Date.now(); - - // Get the pre-signed transaction for this index - const hasPreSignedTx = currentChainData.signedTransactions && - txIndex < currentChainData.signedTransactions.length && - currentChainData.signedTransactions[txIndex] !== null; - - if (hasPreSignedTx) { - // Use pre-signed transaction (we know it exists due to hasPreSignedTx check) - const serializedTransaction = currentChainData.signedTransactions![txIndex] as Buffer; - - // Send the pre-signed transaction - signature = await currentChainData.connection.sendRawTransaction( - serializedTransaction, - { - skipPreflight: false, - preflightCommitment: (chain as SolanaChainConfig).commitment, - } - ); - - // Wait for confirmation - await currentChainData.connection.confirmTransaction( - signature, - (chain as SolanaChainConfig).commitment - ); - - // Calculate transaction latency - const txEndTime = Date.now(); - txLatency = txEndTime - txStartTime; - - // Update result with transaction signature - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { ...r, signature } // Store Solana signature - : r - ) - ); - } else { - // Fallback: create fresh transaction if no pre-signed transaction available - console.warn(`No pre-signed transaction for Solana tx #${txIndex}, creating fresh transaction`); - - const transaction = new Transaction().add( - SystemProgram.transfer({ - fromPubkey: solanaKeypair.publicKey, - toPubkey: solanaKeypair.publicKey, - lamports: txIndex + 1, // Use different amounts to make transactions unique - }) - ); - - signature = await sendAndConfirmTransaction( - currentChainData.connection, - transaction, - [solanaKeypair], - { - commitment: (chain as SolanaChainConfig).commitment, - preflightCommitment: (chain as SolanaChainConfig).commitment, - } - ); - - // Calculate transaction latency - const txEndTime = Date.now(); - txLatency = txEndTime - txStartTime; - - // Update result with transaction signature - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { ...r, signature } // Store Solana signature - : r - ) - ); - } - - // Transaction confirmed, update completed count and track latencies - setResults((prev) => { - const updatedResults = prev.map(r => { - if (r.chainId === chainId) { - // Add this transaction's latency to the array - const newLatencies = [...r.txLatencies, txLatency]; - - const txCompleted = r.txCompleted + 1; - const allTxCompleted = txCompleted >= transactionCount; - - // Calculate total and average latency if we have latencies - const totalLatency = newLatencies.length > 0 - ? newLatencies.reduce((sum, val) => sum + val, 0) - : undefined; - - const averageLatency = totalLatency !== undefined - ? Math.round(totalLatency / newLatencies.length) - : undefined; - - // Ensure status is one of the allowed values from RaceResult.status type - const newStatus: "pending" | "racing" | "success" | "error" = - allTxCompleted ? "success" : "racing"; - - return { - ...r, - txCompleted, - status: newStatus, - txLatencies: newLatencies, - averageLatency, - totalLatency - }; - } - return r; - }); - - // Only determine rankings when chains finish all transactions - const finishedResults = updatedResults - .filter(r => r.status === "success") - .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); - - // Assign positions to finished results - finishedResults.forEach((result, idx) => { - const position = idx + 1; - updatedResults.forEach((r, i) => { - if (r.chainId === result.chainId) { - updatedResults[i] = { ...r, position }; - } - }); - }); - - return updatedResults; - }); - } catch (error) { - console.error(`Solana race error for chain ${chainId}, tx #${txIndex}:`, error); - - // Provide a more user-friendly error message - let errorMessage = "Solana transaction failed"; - - if (error instanceof Error) { - const fullMessage = error.message; - - if (fullMessage.includes("insufficient funds")) { - errorMessage = "Insufficient SOL for transaction fees."; - } else if (fullMessage.includes("blockhash not found")) { - errorMessage = "Transaction expired. Please try again."; - } else if (fullMessage.includes("timeout")) { - errorMessage = "Solana network timeout. Please try again."; - } else { - // Use the first line of the error message if available - const firstLine = fullMessage.split('\n')[0]; - errorMessage = firstLine || fullMessage; - } - } - - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { - ...r, - status: "error" as const, - error: errorMessage - } - : r - ) - ); - break; // Stop sending transactions for this chain if there's an error - } - } - } else if (isSoonChain(chain)) { - // SOON chain transaction processing - similar to Solana since it's SVM-based - const currentChainData = chainData.get(chainId); - - if (!currentChainData || !currentChainData.connection) { - console.error(`No connection data for SOON chain ${chainId}`); - return; - } - - // Run the specified number of transactions - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Skip if chain already had an error - const currentState = results.find(r => r.chainId === chainId); - if (currentState?.status === "error") { - break; - } - - let txLatency = 0; - let signature: string; - const txStartTime = Date.now(); - - // Get the pre-signed transaction for this index - const hasPreSignedTx = currentChainData.signedTransactions && - txIndex < currentChainData.signedTransactions.length && - currentChainData.signedTransactions[txIndex] !== null; - - if (hasPreSignedTx) { - // Use pre-signed transaction - const serializedTransaction = currentChainData.signedTransactions![txIndex] as Buffer; - - // Send the pre-signed transaction - signature = await currentChainData.connection.sendRawTransaction( - serializedTransaction, - { - skipPreflight: false, - preflightCommitment: (chain as SoonChainConfig).commitment, - } - ); - - // Wait for confirmation - await currentChainData.connection.confirmTransaction( - signature, - (chain as SoonChainConfig).commitment - ); - - // Calculate transaction latency - txLatency = Date.now() - txStartTime; - } else { - console.warn(`No pre-signed transaction found for SOON tx #${txIndex}, skipping`); - continue; - } - - // Store the signature hash for this specific transaction - if (txIndex === 0) { - // Store signature in the first transaction only to avoid overwriting - setResults((prev) => - prev.map((r) => - r.chainId === chainId - ? { ...r, signature } // Store SOON signature - : r - ) - ); - } - - // Transaction confirmed, update completed count and track latencies - setResults((prev) => { - const updatedResults = prev.map(r => { - if (r.chainId === chainId) { - // Add this transaction's latency to the array - const newLatencies = [...r.txLatencies, txLatency]; - - const txCompleted = r.txCompleted + 1; - const allTxCompleted = txCompleted >= transactionCount; - - // Calculate total and average latency if we have latencies - const totalLatency = newLatencies.length > 0 - ? newLatencies.reduce((sum, val) => sum + val, 0) - : undefined; - - const averageLatency = totalLatency !== undefined - ? Math.round(totalLatency / newLatencies.length) - : undefined; - - // Ensure status is one of the allowed values from RaceResult.status type - const newStatus: "pending" | "racing" | "success" | "error" = - allTxCompleted ? "success" : "racing"; - - return { - ...r, - txCompleted, - status: newStatus, - txLatencies: newLatencies, - averageLatency, - totalLatency - }; - } - return r; - }); - - return updatedResults; - }); - - } catch (error) { - console.error(`SOON transaction ${txIndex} failed for ${chain.id}:`, error); - // Mark this chain as having an error - setResults((prev) => - prev.map((r) => - r.chainId === chainId - ? { - ...r, - status: "error" as const, - error: error instanceof Error ? error.message : String(error), - } - : r - ) - ); - break; // Stop sending transactions for this chain if there's an error - } - } - } else if (isFuelChain(chain)) { - // Fuel chain transaction processing - const currentChainData = chainData.get(chainId); - - if (!currentChainData) { - console.error(`No wallet data for Fuel chain ${chainId}`); - return; - } - - const fuelWalletUnlocked = fuelWallet as WalletUnlocked; - const provider = new Provider(chain.rpcUrls.public.http[0]); - fuelWalletUnlocked.connect(provider); - const baseAssetId = await provider.getBaseAssetId(); - let lastETHResolvedOutput: ResolvedOutput[] | null = null; - - // Run the specified number of transactions - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Skip if chain already had an error - const currentState = results.find(r => r.chainId === chainId); - if (currentState?.status === "error") { - break; - } - - let txLatency = 0; - const txStartTime = Date.now(); - let tx; - - if (txIndex === 0) { - // First transaction - use pre-signed transaction - if (!currentChainData.signedTransactions) { - throw new Error("No pre-signed transaction available"); - } - const signedTransaction = currentChainData.signedTransactions[0]; - if (!signedTransaction) { - throw new Error("No pre-signed transaction available"); - } - tx = await provider.sendTransaction(signedTransaction as TransactionRequest, { estimateTxDependencies: false }); - - const preConfOutput = await tx.waitForPreConfirmation(); - if (preConfOutput.resolvedOutputs) { - const ethUTXO = preConfOutput.resolvedOutputs.find( - (output) => (output.output as OutputChange).assetId === baseAssetId - ); - if (ethUTXO) { - lastETHResolvedOutput = [ethUTXO]; - } - } - } else { - // Subsequent transactions using previous UTXO - if (!lastETHResolvedOutput || lastETHResolvedOutput.length === 0) { - throw new Error("No resolved output available for subsequent transaction"); - } - - const scriptRequest = new ScriptTransactionRequest({ - script: "0x" - }); - scriptRequest.maxFee = bn(100); - - const [{ utxoId, output }] = lastETHResolvedOutput; - const change = output as unknown as { - assetId: string; - amount: string; - }; - - const resource = { - id: utxoId, - assetId: change.assetId, - amount: bn(change.amount), - owner: fuelWalletUnlocked.address, - blockCreated: bn(0), - txCreatedIdx: bn(0), - }; - - scriptRequest.addResource(resource); - const signedTransaction = await fuelWalletUnlocked.populateTransactionWitnessesSignature(scriptRequest); - tx = await provider.sendTransaction(signedTransaction as TransactionRequest, { estimateTxDependencies: false }); - - const preConfOutput = await tx.waitForPreConfirmation(); - if (preConfOutput.resolvedOutputs) { - const ethUTXO = preConfOutput.resolvedOutputs.find( - (output) => (output.output as OutputChange).assetId === baseAssetId - ); - if (ethUTXO) { - lastETHResolvedOutput = [ethUTXO]; - } - } - } - - if (!tx) { - throw new Error("Failed to send transaction"); - } - - // Calculate transaction latency - const txEndTime = Date.now(); - txLatency = txEndTime - txStartTime; - - // Update result with transaction hash - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { ...r, txHash: `0x${tx.id}` } - : r - ) - ); - - // Transaction confirmed, update completed count and track latencies - setResults((prev) => { - const updatedResults = prev.map(r => { - if (r.chainId === chainId) { - const newLatencies = [...r.txLatencies, txLatency]; - const txCompleted = r.txCompleted + 1; - const allTxCompleted = txCompleted >= transactionCount; - - const totalLatency = newLatencies.length > 0 - ? newLatencies.reduce((sum, val) => sum + val, 0) - : undefined; - - const averageLatency = totalLatency !== undefined - ? Math.round(totalLatency / newLatencies.length) - : undefined; - - const newStatus: "pending" | "racing" | "success" | "error" = - allTxCompleted ? "success" : "racing"; - - return { - ...r, - txCompleted, - status: newStatus, - txLatencies: newLatencies, - averageLatency, - totalLatency - }; - } - return r; - }); - - // Only determine rankings when chains finish all transactions - const finishedResults = updatedResults - .filter(r => r.status === "success") - .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); - - // Assign positions to finished results - finishedResults.forEach((result, idx) => { - const position = idx + 1; - updatedResults.forEach((r, i) => { - if (r.chainId === result.chainId) { - updatedResults[i] = { ...r, position }; - } - }); - }); - - return updatedResults; - }); - } catch (error) { - console.error(`Fuel race error for chain ${chainId}, tx #${txIndex}:`, error); - - let errorMessage = "Fuel transaction failed"; - - if (error instanceof Error) { - const fullMessage = error.message; - - if (fullMessage.includes("insufficient funds")) { - errorMessage = "Insufficient ETH for transaction fees."; - } else if (fullMessage.includes("timeout")) { - errorMessage = "Fuel network timeout. Please try again."; - } else { - const firstLine = fullMessage.split('\n')[0]; - errorMessage = firstLine || fullMessage; - } - } - - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { - ...r, - status: "error" as const, - error: errorMessage - } - : r - ) - ); - break; - } - } - } else if (isAptosChain(chain)) { - // Aptos chain transaction processing - const currentChainData = chainData.get(chainId); - - if (!currentChainData || !currentChainData.aptos || !currentChainData.signedTransactions) { - console.error(`No Aptos client data for chain ${chainId}`); - return; - } - - const aptos = currentChainData.aptos; - - // Run the specified number of transactions - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - try { - // Skip if chain already had an error - const currentState = results.find(r => r.chainId === chainId); - if (currentState?.status === "error") { - break; - } - - let txLatency = 0; - const txStartTime = Date.now(); - - // Sign and submit the transaction - const signedTransaction = currentChainData.signedTransactions[txIndex]; - if (!signedTransaction || typeof signedTransaction !== "object") { - throw new Error(`No pre-signed transaction available for Aptos tx #${txIndex}`); - } else if (typeof signedTransaction === "object" && !("senderAuthenticator" in signedTransaction)) { - console.error(`Signed transaction for Aptos tx #${txIndex} is missing senderAuthenticator`); - return; - } - - const response = await aptos.transaction.submit.simple(signedTransaction); - - // Wait for transaction confirmation - await aptos.waitForTransaction({ - transactionHash: response.hash, - options: { - waitForIndexer: false // Unnecessary, no indexer calls made - } - }); - - // Calculate transaction latency - const txEndTime = Date.now(); - txLatency = txEndTime - txStartTime; - - // Update result with transaction hash - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { ...r, txHash: response.hash.startsWith('0x') ? response.hash as Hex : `0x${response.hash}` as Hex } - : r - ) - ); - - // Transaction confirmed, update completed count and track latencies - setResults((prev) => { - const updatedResults = prev.map(r => { - if (r.chainId === chainId) { - const newLatencies = [...r.txLatencies, txLatency]; - const txCompleted = r.txCompleted + 1; - const allTxCompleted = txCompleted >= transactionCount; - - const totalLatency = newLatencies.length > 0 - ? newLatencies.reduce((sum, val) => sum + val, 0) - : undefined; - - const averageLatency = totalLatency !== undefined - ? Math.round(totalLatency / newLatencies.length) - : undefined; - - const newStatus: "pending" | "racing" | "success" | "error" = - allTxCompleted ? "success" : "racing"; - - return { - ...r, - txCompleted, - status: newStatus, - txLatencies: newLatencies, - averageLatency, - totalLatency - }; - } - return r; - }); - - // Only determine rankings when chains finish all transactions - const finishedResults = updatedResults - .filter(r => r.status === "success") - .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); - - // Assign positions to finished results - finishedResults.forEach((result, idx) => { - const position = idx + 1; - updatedResults.forEach((r, i) => { - if (r.chainId === result.chainId) { - updatedResults[i] = { ...r, position }; - } - }); - }); - - return updatedResults; - }); - } catch (error) { - console.error(`Aptos race error for chain ${chainId}, tx #${txIndex}:`, error); - - let errorMessage = "Aptos transaction failed"; - - if (error instanceof Error) { - const fullMessage = error.message; - - if (fullMessage.includes("insufficient funds")) { - errorMessage = "Insufficient APT for transaction fees."; - } else if (fullMessage.includes("timeout")) { - errorMessage = "Aptos network timeout. Please try again."; - } else if (fullMessage.includes("SEQUENCE_NUMBER_TOO_OLD")) { - errorMessage = "Transaction sequence error. Please try again."; - } else { - const firstLine = fullMessage.split('\n')[0]; - errorMessage = firstLine || fullMessage; - } - } - - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { - ...r, - status: "error" as const, - error: errorMessage - } - : r - ) - ); - break; - } - } - } else if (isStarknetChain(chain)) { - const currentChainData = chainData.get(chainId); - - if (!currentChainData) { - console.error(`No wallet data for Starknet chain ${chainId}`); - return; - } - - const provider = new RpcProvider({ nodeUrl: chain.endpoint }); - const account = new Account( - provider, - starknetaccount?.address ?? "", - starknetprivateKey ?? "" - ); - const erc20Contract = new Contract(Erc20Abi, STRK_ADDRESS, account); - - for (let txIndex = 0; txIndex < transactionCount; txIndex++) { - const currentNonce = await account.getNonce(); - try { - // Skip if chain already had an error - const currentState = results.find(r => r.chainId === chainId); - if (currentState?.status === "error") { - break; - } - - const startTime = Date.now(); - const amount = cairo.uint256((txIndex + 1) * 10 ** 18); - - const transferCall: Call = erc20Contract.populate("transfer", { - recipient: starknetaccount?.address ?? "", - amount: amount, - }); - - const { transaction_hash: transferTxHash } = await account.execute( - transferCall, - { - nonce: currentNonce, - version: 3, - } - ); - // Wait for transaction confirmation - await provider.waitForTransaction(transferTxHash); - // Calculate transaction latency - const endTime = Date.now(); - const txLatency = endTime - startTime; - - // Update results with transaction hash and latency - setResults(prev => - prev.map(r => { - if (r.chainId === chainId) { - const newLatencies = [...r.txLatencies, txLatency]; - const txCompleted = r.txCompleted + 1; - const allTxCompleted = txCompleted >= transactionCount; - - const totalLatency = newLatencies.reduce((sum, val) => sum + val, 0); - const averageLatency = Math.round(totalLatency / newLatencies.length); - - return { - ...r, - txHash: transferTxHash as `0x${string}`, - txCompleted, - status: allTxCompleted ? "success" : "racing", - txLatencies: newLatencies, - averageLatency, - totalLatency - }; - } - return r; - }) - ); - - } catch (error) { - console.error(`Starknet race error for chain ${chainId}, tx #${txIndex}:`, error); - - let errorMessage = "Starknet transaction failed"; - if (error instanceof Error) { - const fullMessage = error.message; - if (fullMessage.includes("insufficient funds")) { - errorMessage = "Insufficient STRK for transaction fees."; - } else if (fullMessage.includes("nonce")) { - errorMessage = "Transaction nonce issue. Please try again."; - } else if (fullMessage.includes("timeout")) { - errorMessage = "Starknet network timeout. Please try again."; - } else { - const firstLine = fullMessage.split('\n')[0]; - errorMessage = firstLine || fullMessage; - } - } - - setResults(prev => - prev.map(r => - r.chainId === chainId - ? { - ...r, - status: "error" as const, - error: errorMessage - } - : r - ) - ); - break; - } + break; } } } catch (error) { - console.error(`Race initialization error for chain ${chainId}:`, error); + console.error(`Race initialization error for ${adapter.name}:`, error); setResults(prev => prev.map(r => - r.chainId === chainId + r.chainId === adapter.chainId ? { ...r, status: "error" as const, @@ -2198,30 +457,95 @@ export function useChainRace() { }, 1000); }; - // Reset everything to prepare for a new race + // Save race results effect (same as original) + useEffect(() => { + const saveResults = async () => { + if (status === 'finished' && results.length > 0 && account) { + try { + const isDevelopment = process.env.NODE_ENV === 'development'; + + if (isDevelopment) { + console.log('🏁 [Chain Derby] Race finished! Preparing to save results...'); + } + + const geo = await getGeo(); + + const chainResults: ChainResultPayload[] = results.map(result => { + let numericChainId: number; + if (typeof result.chainId === 'string') { + if (result.chainId.includes('solana')) { + numericChainId = 999999; + } else if (result.chainId.includes('aptos-testnet')) { + numericChainId = 999998; + } else if (result.chainId.includes('aptos-mainnet')) { + numericChainId = 999997; + } else { + numericChainId = 999996; + } + } else { + numericChainId = result.chainId; + } + + return { + chainId: numericChainId, + chainName: result.name, + txLatencies: result.txLatencies, + averageLatency: result.averageLatency || 0, + totalLatency: result.totalLatency || 0, + status: result.status, + position: result.position, + }; + }); + + const payload: RaceSessionPayload = { + title: `Chain Derby Race - ${new Date().toISOString()}`, + walletAddress: account.address, + transactionCount, + status: 'completed', + city: geo.city, + region: geo.region, + country: geo.country, + results: chainResults, + }; + + await saveRaceResults(payload); + + if (isDevelopment) { + console.log('🎉 [Chain Derby] Race results saved successfully!'); + } + } catch (error) { + const isDevelopment = process.env.NODE_ENV === 'development'; + if (isDevelopment) { + console.error('❌ [Chain Derby] Failed to save race results:', error); + } + } + } + }; + + saveResults(); + }, [status, results, account, transactionCount]); + + // Utility functions const resetRace = () => { setStatus("idle"); setBalances([]); setResults([]); }; - // Start a new race with the same configuration (when already in finished state) const restartRace = () => { - // Keep the balances but reset the results setStatus("ready"); setResults([]); }; - // Skip a specific chain during the race const skipChain = (chainId: number | string) => { setResults(prev => prev.map(r => r.chainId === chainId ? { ...r, - status: "success" as const, // Use const assertion to ensure correct type - txCompleted: r.txTotal, // Mark all transactions as completed - position: 999, // Put it at the end of the results + status: "success" as const, + txCompleted: r.txTotal, + position: 999, error: "Skipped by user" } : r @@ -2229,6 +553,7 @@ export function useChainRace() { ); }; + // Return exact same interface as original hook return { status, balances, @@ -2247,10 +572,8 @@ export function useChainRace() { resetWallet, selectedChains, setSelectedChains, - // Layer filtering layerFilter, setLayerFilter, - // Network filtering networkFilter, setNetworkFilter, getFilteredChains, From 484fcdf006deb96ef43f9098d8b777ce2848186f Mon Sep 17 00:00:00 2001 From: Samarth Saxena Date: Mon, 16 Jun 2025 13:34:01 +0000 Subject: [PATCH 2/2] fix --- packages/app/src/hooks/useChainRace.ts | 224 ++++++++++++++----------- 1 file changed, 122 insertions(+), 102 deletions(-) diff --git a/packages/app/src/hooks/useChainRace.ts b/packages/app/src/hooks/useChainRace.ts index cfecb03..5929eb5 100644 --- a/packages/app/src/hooks/useChainRace.ts +++ b/packages/app/src/hooks/useChainRace.ts @@ -323,123 +323,143 @@ export function useChainRace() { ChainAdapterFactory.create(chain, walletStates) ); - // Initialize results - const initialResults = activeAdapters.map(adapter => ({ - chainId: adapter.chainId, - name: adapter.name, - color: adapter.color, - logo: adapter.logo, - status: "pending" as const, - txCompleted: 0, - txTotal: transactionCount, - txLatencies: [], - })); - - setResults(initialResults); - - // Run races in parallel for each adapter - activeAdapters.forEach(async (adapter) => { - try { - // Update status to racing - setResults(prev => - prev.map(r => r.chainId === adapter.chainId ? { ...r, status: "racing" } : r) - ); + try { + // Pre-prepare all transactions for all adapters FIRST + const preparationPromises = activeAdapters.map(async (adapter) => { + try { + const preparedTransactions = await adapter.prepareTransactions(transactionCount); + return { adapter, preparedTransactions }; + } catch (error) { + console.error(`Failed to prepare transactions for ${adapter.name}:`, error); + return { adapter, preparedTransactions: [], error }; + } + }); + + const preparedData = await Promise.all(preparationPromises); - // Prepare transactions - const preparedTransactions = await adapter.prepareTransactions(transactionCount); + // Now initialize results AFTER preparation is complete + const initialResults = activeAdapters.map(adapter => ({ + chainId: adapter.chainId, + name: adapter.name, + color: adapter.color, + logo: adapter.logo, + status: "pending" as const, + txCompleted: 0, + txTotal: transactionCount, + txLatencies: [], + })); - // Execute transactions sequentially for each chain - for (let i = 0; i < transactionCount; i++) { - try { - // Check if chain already errored - const currentState = results.find(r => r.chainId === adapter.chainId); - if (currentState?.status === "error") break; + setResults(initialResults); - const preparedTx = preparedTransactions[i]; - if (!preparedTx) continue; + // Run races in parallel for each adapter with pre-prepared transactions + preparedData.forEach(async ({ adapter, preparedTransactions, error }) => { + try { + if (error) { + throw new Error(`Preparation failed: ${error}`); + } - // Execute transaction - const result = await adapter.executeTransaction(preparedTx); + // Update status to racing + setResults(prev => + prev.map(r => r.chainId === adapter.chainId ? { ...r, status: "racing" } : r) + ); - if (!result.success) { - throw new Error(result.error || "Transaction failed"); - } + // Execute transactions sequentially for each chain + for (let i = 0; i < transactionCount; i++) { + try { + // Check if chain already errored + const currentState = results.find(r => r.chainId === adapter.chainId); + if (currentState?.status === "error") break; - // Update results - setResults(prev => { - const updatedResults = prev.map(r => { - if (r.chainId === adapter.chainId) { - const newLatencies = [...r.txLatencies, result.latency]; - const txCompleted = r.txCompleted + 1; - const allTxCompleted = txCompleted >= transactionCount; - - const totalLatency = newLatencies.reduce((sum, val) => sum + val, 0); - const averageLatency = Math.round(totalLatency / newLatencies.length); - - return { - ...r, - txHash: result.hash || r.txHash, - signature: result.signature || r.signature, - txCompleted, - status: allTxCompleted ? "success" as const : "racing" as const, - txLatencies: newLatencies, - averageLatency, - totalLatency - }; - } - return r; - }); + const preparedTx = preparedTransactions[i]; + if (!preparedTx) continue; + + // Execute transaction + const result = await adapter.executeTransaction(preparedTx); - // Update positions - const finishedResults = updatedResults - .filter(r => r.status === "success") - .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); + if (!result.success) { + throw new Error(result.error || "Transaction failed"); + } - finishedResults.forEach((result, idx) => { - const position = idx + 1; - updatedResults.forEach((r, i) => { - if (r.chainId === result.chainId) { - updatedResults[i] = { ...r, position }; + // Update results + setResults(prev => { + const updatedResults = prev.map(r => { + if (r.chainId === adapter.chainId) { + const newLatencies = [...r.txLatencies, result.latency]; + const txCompleted = r.txCompleted + 1; + const allTxCompleted = txCompleted >= transactionCount; + + const totalLatency = newLatencies.reduce((sum, val) => sum + val, 0); + const averageLatency = Math.round(totalLatency / newLatencies.length); + + return { + ...r, + txHash: result.hash || r.txHash, + signature: result.signature || r.signature, + txCompleted, + status: allTxCompleted ? "success" as const : "racing" as const, + txLatencies: newLatencies, + averageLatency, + totalLatency + }; } + return r; }); + + // Update positions + const finishedResults = updatedResults + .filter(r => r.status === "success") + .sort((a, b) => (a.averageLatency || Infinity) - (b.averageLatency || Infinity)); + + finishedResults.forEach((result, idx) => { + const position = idx + 1; + updatedResults.forEach((r, i) => { + if (r.chainId === result.chainId) { + updatedResults[i] = { ...r, position }; + } + }); + }); + + return updatedResults; }); - return updatedResults; - }); - - } catch (error) { - console.error(`Race error for ${adapter.name}, tx #${i}:`, error); - - setResults(prev => - prev.map(r => - r.chainId === adapter.chainId - ? { - ...r, - status: "error" as const, - error: error instanceof Error ? error.message : "Transaction failed" - } - : r - ) - ); - break; + } catch (error) { + console.error(`Race error for ${adapter.name}, tx #${i}:`, error); + + setResults(prev => + prev.map(r => + r.chainId === adapter.chainId + ? { + ...r, + status: "error" as const, + error: error instanceof Error ? error.message : "Transaction failed" + } + : r + ) + ); + break; + } } + + } catch (error) { + console.error(`Race initialization error for ${adapter.name}:`, error); + setResults(prev => + prev.map(r => + r.chainId === adapter.chainId + ? { + ...r, + status: "error" as const, + error: error instanceof Error ? error.message : "Race initialization failed" + } + : r + ) + ); } + }); - } catch (error) { - console.error(`Race initialization error for ${adapter.name}:`, error); - setResults(prev => - prev.map(r => - r.chainId === adapter.chainId - ? { - ...r, - status: "error" as const, - error: error instanceof Error ? error.message : "Race initialization failed" - } - : r - ) - ); - } - }); + } catch (error) { + console.error("Error preparing race:", error); + setStatus("ready"); // Reset status on preparation failure + } // Check if race is complete periodically const checkRaceComplete = setInterval(() => {