diff --git a/backend/services/blockchain-service/src/config/blockchain.config.ts b/backend/services/blockchain-service/src/config/blockchain.config.ts index b7c7e012..5acc131d 100644 --- a/backend/services/blockchain-service/src/config/blockchain.config.ts +++ b/backend/services/blockchain-service/src/config/blockchain.config.ts @@ -30,6 +30,8 @@ export default registerAs('blockchain', () => { ? { // KAVA Testnet rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.testnet.kava.io', + // 逗号分隔的多个 RPC URL,用于故障转移(可选,不配置则仅使用 rpcUrl) + rpcUrls: process.env.KAVA_RPC_URLS || '', chainId: parseInt(process.env.KAVA_CHAIN_ID || '2221', 10), // 测试网 USDT 合约 (自定义部署的 TestUSDT) usdtContract: process.env.KAVA_USDT_CONTRACT || '0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF', @@ -38,6 +40,8 @@ export default registerAs('blockchain', () => { : { // KAVA Mainnet rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io', + // 逗号分隔的多个 RPC URL,用于故障转移(可选,不配置则仅使用 rpcUrl) + rpcUrls: process.env.KAVA_RPC_URLS || '', chainId: parseInt(process.env.KAVA_CHAIN_ID || '2222', 10), // dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位 usdtContract: process.env.KAVA_USDT_CONTRACT || '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3', @@ -49,6 +53,7 @@ export default registerAs('blockchain', () => { ? { // BSC Testnet (BNB Smart Chain Testnet) rpcUrl: process.env.BSC_RPC_URL || 'https://data-seed-prebsc-1-s1.binance.org:8545', + rpcUrls: process.env.BSC_RPC_URLS || '', chainId: parseInt(process.env.BSC_CHAIN_ID || '97', 10), // BSC Testnet 官方测试 USDT 合约 usdtContract: process.env.BSC_USDT_CONTRACT || '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd', @@ -57,6 +62,7 @@ export default registerAs('blockchain', () => { : { // BSC Mainnet rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org', + rpcUrls: process.env.BSC_RPC_URLS || '', chainId: parseInt(process.env.BSC_CHAIN_ID || '56', 10), usdtContract: process.env.BSC_USDT_CONTRACT || '0x55d398326f99059fF775485246999027B3197955', confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '15', 10), diff --git a/backend/services/blockchain-service/src/domain/domain.module.ts b/backend/services/blockchain-service/src/domain/domain.module.ts index 758adab7..08824001 100644 --- a/backend/services/blockchain-service/src/domain/domain.module.ts +++ b/backend/services/blockchain-service/src/domain/domain.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; -import { ConfirmationPolicyService, ChainConfigService } from './services'; +import { ConfirmationPolicyService, ChainConfigService, RpcProviderManager } from './services'; import { Erc20TransferService } from './services/erc20-transfer.service'; @Module({ - providers: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService], - exports: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService], + providers: [ConfirmationPolicyService, ChainConfigService, RpcProviderManager, Erc20TransferService], + exports: [ConfirmationPolicyService, ChainConfigService, RpcProviderManager, Erc20TransferService], }) export class DomainModule {} diff --git a/backend/services/blockchain-service/src/domain/services/chain-config.service.ts b/backend/services/blockchain-service/src/domain/services/chain-config.service.ts index 3c303a81..11aef458 100644 --- a/backend/services/blockchain-service/src/domain/services/chain-config.service.ts +++ b/backend/services/blockchain-service/src/domain/services/chain-config.service.ts @@ -7,6 +7,8 @@ export interface ChainConfig { chainType: ChainTypeEnum; chainId: number; rpcUrl: string; + /** RPC URL 列表(含主端点和备选端点),用于故障转移 */ + rpcUrls: string[]; usdtContract: string; nativeSymbol: string; blockTime: number; // 平均出块时间(秒) @@ -42,6 +44,13 @@ export class ChainConfigService { 'blockchain.kava.rpcUrl', this.isTestnet ? 'https://evm.testnet.kava.io' : 'https://evm.kava.io', ), + rpcUrls: this.parseRpcUrls( + 'blockchain.kava.rpcUrls', + this.configService.get( + 'blockchain.kava.rpcUrl', + this.isTestnet ? 'https://evm.testnet.kava.io' : 'https://evm.kava.io', + ), + ), // dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位 usdtContract: this.configService.get( 'blockchain.kava.usdtContract', @@ -61,6 +70,13 @@ export class ChainConfigService { 'blockchain.bsc.rpcUrl', this.isTestnet ? 'https://data-seed-prebsc-1-s1.binance.org:8545' : 'https://bsc-dataseed.binance.org', ), + rpcUrls: this.parseRpcUrls( + 'blockchain.bsc.rpcUrls', + this.configService.get( + 'blockchain.bsc.rpcUrl', + this.isTestnet ? 'https://data-seed-prebsc-1-s1.binance.org:8545' : 'https://bsc-dataseed.binance.org', + ), + ), usdtContract: this.configService.get( 'blockchain.bsc.usdtContract', this.isTestnet ? '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd' : '0x55d398326f99059fF775485246999027B3197955', @@ -114,4 +130,24 @@ export class ChainConfigService { isSupported(chainType: ChainType): boolean { return this.configs.has(chainType.value); } + + /** + * 解析 RPC URL 列表 + * + * 如果配置了逗号分隔的多 URL(如 KAVA_RPC_URLS),使用它作为完整列表; + * 否则回退到单个 rpcUrl,行为与之前完全一致。 + */ + private parseRpcUrls(configKey: string, fallbackUrl: string): string[] { + const urlsStr = this.configService.get(configKey, ''); + if (urlsStr) { + const urls = urlsStr + .split(',') + .map((u) => u.trim()) + .filter((u) => u.length > 0); + if (urls.length > 0) { + return urls; + } + } + return [fallbackUrl]; + } } diff --git a/backend/services/blockchain-service/src/domain/services/erc20-transfer.service.ts b/backend/services/blockchain-service/src/domain/services/erc20-transfer.service.ts index 51cd37ba..1cd7fa4e 100644 --- a/backend/services/blockchain-service/src/domain/services/erc20-transfer.service.ts +++ b/backend/services/blockchain-service/src/domain/services/erc20-transfer.service.ts @@ -10,6 +10,7 @@ import { recoverAddress, } from 'ethers'; import { ChainConfigService } from './chain-config.service'; +import { RpcProviderManager } from './rpc-provider-manager.service'; import { ChainType } from '@/domain/value-objects'; import { ChainTypeEnum } from '@/domain/enums'; @@ -47,16 +48,16 @@ export const MPC_SIGNING_CLIENT = Symbol('MPC_SIGNING_CLIENT'); @Injectable() export class Erc20TransferService { private readonly logger = new Logger(Erc20TransferService.name); - private readonly providers: Map = new Map(); private readonly hotWalletAddress: string; private mpcSigningClient: IMpcSigningClient | null = null; constructor( private readonly configService: ConfigService, private readonly chainConfig: ChainConfigService, + private readonly rpcProviderManager: RpcProviderManager, ) { this.hotWalletAddress = this.configService.get('HOT_WALLET_ADDRESS', ''); - this.initializeProviders(); + this.initializeWalletConfig(); } /** @@ -67,19 +68,40 @@ export class Erc20TransferService { this.logger.log(`[INIT] MPC Signing Client injected`); } - private initializeProviders(): void { - // 为每条支持的链创建 Provider - for (const chainType of this.chainConfig.getSupportedChains()) { - try { - const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType)); - const provider = new JsonRpcProvider(config.rpcUrl, config.chainId); - this.providers.set(chainType, provider); - this.logger.log(`[INIT] Provider initialized for ${chainType}: ${config.rpcUrl}`); - } catch (error) { - this.logger.error(`[INIT] Failed to initialize provider for ${chainType}`, error); - } - } + /** + * 获取某条链当前活跃的 provider(由 RpcProviderManager 统一管理,支持故障转移) + */ + private getProvider(chainType: ChainTypeEnum): JsonRpcProvider { + return this.rpcProviderManager.getProvider(chainType); + } + /** + * 判断错误是否为 RPC 连接类错误(需要触发故障转移) + * 区分 RPC 网络错误(503、超时等)和合约执行错误(revert、余额不足等) + */ + private isRpcConnectionError(error: any): boolean { + const message = (error?.message || '').toLowerCase(); + return ( + message.includes('could not detect network') || + message.includes('connection refused') || + message.includes('timeout') || + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('503') || + message.includes('502') || + message.includes('server error') || + message.includes('missing response') || + message.includes('request failed') || + error?.code === 'NETWORK_ERROR' || + error?.code === 'SERVER_ERROR' || + error?.code === 'TIMEOUT' + ); + } + + /** + * 初始化钱包配置检查(provider 由 RpcProviderManager 统一管理) + */ + private initializeWalletConfig(): void { // 检查热钱包地址配置 if (this.hotWalletAddress) { this.logger.log(`[INIT] Hot wallet address configured: ${this.hotWalletAddress}`); @@ -100,10 +122,7 @@ export class Erc20TransferService { * 获取热钱包 USDT 余额 */ async getHotWalletBalance(chainType: ChainTypeEnum): Promise { - const provider = this.providers.get(chainType); - if (!provider) { - throw new Error(`Provider not configured for chain: ${chainType}`); - } + const provider = this.getProvider(chainType); if (!this.hotWalletAddress) { throw new Error('Hot wallet address not configured'); @@ -136,12 +155,7 @@ export class Erc20TransferService { this.logger.log(`[TRANSFER] To: ${toAddress}`); this.logger.log(`[TRANSFER] Amount: ${amount} USDT`); - const provider = this.providers.get(chainType); - if (!provider) { - const error = `Provider not configured for chain: ${chainType}`; - this.logger.error(`[TRANSFER] ${error}`); - return { success: false, error }; - } + const provider = this.getProvider(chainType); if (!this.mpcSigningClient || !this.mpcSigningClient.isConfigured()) { const error = 'MPC signing client not configured'; @@ -296,6 +310,7 @@ export class Erc20TransferService { this.logger.log(`[TRANSFER] Block: ${receipt.blockNumber}`); this.logger.log(`[TRANSFER] Gas used: ${receipt.gasUsed.toString()}`); + this.rpcProviderManager.reportSuccess(chainType); return { success: true, txHash: txResponse.hash, @@ -308,6 +323,9 @@ export class Erc20TransferService { return { success: false, txHash: txResponse.hash, error }; } } catch (error: any) { + if (this.isRpcConnectionError(error)) { + this.rpcProviderManager.reportFailure(chainType, error); + } this.logger.error(`[TRANSFER] Transfer failed:`, error); return { success: false, @@ -320,8 +338,11 @@ export class Erc20TransferService { * 检查热钱包是否已配置 */ isConfigured(chainType: ChainTypeEnum): boolean { - return this.providers.has(chainType) && - !!this.hotWalletAddress && - !!this.mpcSigningClient?.isConfigured(); + try { + this.rpcProviderManager.getProvider(chainType); + return !!this.hotWalletAddress && !!this.mpcSigningClient?.isConfigured(); + } catch { + return false; + } } } diff --git a/backend/services/blockchain-service/src/domain/services/index.ts b/backend/services/blockchain-service/src/domain/services/index.ts index 7bf6b5cd..dfc3a7dc 100644 --- a/backend/services/blockchain-service/src/domain/services/index.ts +++ b/backend/services/blockchain-service/src/domain/services/index.ts @@ -1,2 +1,3 @@ export * from './confirmation-policy.service'; export * from './chain-config.service'; +export * from './rpc-provider-manager.service'; diff --git a/backend/services/blockchain-service/src/domain/services/rpc-provider-manager.service.ts b/backend/services/blockchain-service/src/domain/services/rpc-provider-manager.service.ts new file mode 100644 index 00000000..2f43561a --- /dev/null +++ b/backend/services/blockchain-service/src/domain/services/rpc-provider-manager.service.ts @@ -0,0 +1,212 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { JsonRpcProvider } from 'ethers'; +import { ChainConfigService } from './chain-config.service'; +import { ChainTypeEnum } from '@/domain/enums'; + +/** + * 每条链的 RPC 端点健康状态 + */ +interface RpcHealthState { + /** 当前活跃的 JsonRpcProvider 实例 */ + provider: JsonRpcProvider; + /** 该链可用的所有 RPC URL 列表(第一个为默认主端点) */ + urls: string[]; + /** 当前使用的 URL 在 urls 数组中的索引 */ + currentIndex: number; + /** 该链的 chainId(用于创建新 provider) */ + chainId: number; + /** 首次连续失败的时间戳(null 表示当前健康) */ + firstFailureAt: number | null; + /** 连续失败次数(用于日志) */ + consecutiveFailures: number; +} + +/** + * RPC Provider 管理器 — 自动故障转移 + * + * 集中管理各链的 JsonRpcProvider 实例。当某条链的 RPC 端点 + * 持续失败超过 FAILOVER_THRESHOLD_MS(默认 3 分钟)时, + * 自动切换到下一个备选端点。 + * + * 使用方式: + * - EvmProviderAdapter 和 Erc20TransferService 通过此服务获取 provider + * - RPC 调用成功后调用 reportSuccess(chain) 重置失败状态 + * - RPC 调用失败后调用 reportFailure(chain, error) 记录失败 + * - 超过阈值后自动轮转到下一个 URL + * + * 环境变量: + * - KAVA_RPC_URLS: 逗号分隔的多个 Kava RPC URL(可选,默认使用 KAVA_RPC_URL) + * - BSC_RPC_URLS: 逗号分隔的多个 BSC RPC URL(可选,默认使用 BSC_RPC_URL) + */ +@Injectable() +export class RpcProviderManager implements OnModuleInit { + private readonly logger = new Logger(RpcProviderManager.name); + private readonly healthStates: Map = new Map(); + + /** 持续失败多久后触发端点切换(毫秒),默认 3 分钟 */ + private readonly FAILOVER_THRESHOLD_MS = 3 * 60 * 1000; + + constructor(private readonly chainConfig: ChainConfigService) {} + + onModuleInit(): void { + this.initializeAllChains(); + } + + /** + * 初始化所有支持链的 provider + * 从 ChainConfig 中读取 rpcUrls 列表,创建初始 provider + */ + private initializeAllChains(): void { + for (const chainType of this.chainConfig.getSupportedChains()) { + const config = this.chainConfig.getConfig( + { value: chainType, toString: () => chainType } as any, + ); + + const urls = config.rpcUrls; + const primaryUrl = urls[0]; + const provider = new JsonRpcProvider(primaryUrl, config.chainId); + + this.healthStates.set(chainType, { + provider, + urls, + currentIndex: 0, + chainId: config.chainId, + firstFailureAt: null, + consecutiveFailures: 0, + }); + + if (urls.length > 1) { + this.logger.log( + `[INIT] ${chainType} RPC 端点列表 (${urls.length} 个): ${urls.join(', ')}`, + ); + } else { + this.logger.log( + `[INIT] ${chainType} RPC 端点: ${primaryUrl}(未配置备选端点)`, + ); + } + } + } + + /** + * 获取某条链当前活跃的 provider + * + * @param chain 链类型枚举 + * @returns 当前活跃的 JsonRpcProvider + * @throws Error 如果该链未初始化 + */ + getProvider(chain: ChainTypeEnum): JsonRpcProvider { + const state = this.healthStates.get(chain); + if (!state) { + throw new Error(`RPC Provider 未初始化: ${chain}`); + } + return state.provider; + } + + /** + * 获取某条链当前使用的 RPC URL(用于日志/调试) + */ + getCurrentUrl(chain: ChainTypeEnum): string { + const state = this.healthStates.get(chain); + return state ? state.urls[state.currentIndex] : 'unknown'; + } + + /** + * 获取某条链的所有可用 RPC URL 数量 + */ + getUrlCount(chain: ChainTypeEnum): number { + const state = this.healthStates.get(chain); + return state ? state.urls.length : 0; + } + + /** + * 报告 RPC 调用成功 + * 重置该链的失败计时和连续失败次数 + */ + reportSuccess(chain: ChainTypeEnum): void { + const state = this.healthStates.get(chain); + if (!state) return; + + // 如果之前处于失败状态,记录恢复日志 + if (state.firstFailureAt !== null) { + this.logger.log( + `[${chain}] RPC 恢复正常: ${state.urls[state.currentIndex]}` + + ` (之前连续失败 ${state.consecutiveFailures} 次)`, + ); + } + state.firstFailureAt = null; + state.consecutiveFailures = 0; + } + + /** + * 报告 RPC 调用失败 + * + * 首次失败时记录时间戳。后续持续失败超过 FAILOVER_THRESHOLD_MS 后, + * 自动切换到下一个备选端点。 + * + * @param chain 链类型枚举 + * @param error 可选的错误对象(用于日志) + */ + reportFailure(chain: ChainTypeEnum, error?: Error): void { + const state = this.healthStates.get(chain); + if (!state) return; + + const now = Date.now(); + state.consecutiveFailures++; + + // 首次失败:记录起始时间 + if (state.firstFailureAt === null) { + state.firstFailureAt = now; + this.logger.warn( + `[${chain}] RPC 开始失败: ${state.urls[state.currentIndex]}` + + ` — ${error?.message || 'unknown error'}`, + ); + return; + } + + // 检查是否超过故障转移阈值 + const elapsedMs = now - state.firstFailureAt; + if (elapsedMs >= this.FAILOVER_THRESHOLD_MS) { + this.switchToNextUrl(chain, state); + } else { + // 每 30 秒输出一条持续失败日志,避免日志洪水 + if (state.consecutiveFailures % 6 === 0) { + this.logger.warn( + `[${chain}] RPC 持续失败中 (${Math.round(elapsedMs / 1000)}s / ` + + `${this.FAILOVER_THRESHOLD_MS / 1000}s): ` + + `${state.urls[state.currentIndex]}`, + ); + } + } + } + + /** + * 切换到下一个 RPC URL + * + * 轮转到 urls 列表中的下一个端点,创建新的 JsonRpcProvider 实例。 + * 如果只有一个 URL,则重新创建 provider(处理临时网络问题)。 + */ + private switchToNextUrl(chain: ChainTypeEnum, state: RpcHealthState): void { + const oldUrl = state.urls[state.currentIndex]; + + // 轮转到下一个 URL + state.currentIndex = (state.currentIndex + 1) % state.urls.length; + const newUrl = state.urls[state.currentIndex]; + + if (state.urls.length === 1) { + this.logger.error( + `[${chain}] 仅有一个 RPC URL,无法切换到备选端点,将重新创建 provider: ${newUrl}`, + ); + } else { + this.logger.warn( + `[${chain}] === RPC 端点切换 === ${oldUrl} → ${newUrl}`, + ); + } + + // 创建新的 provider 实例(ethers.js v6 的 JsonRpcProvider 创建后 URL 不可变) + state.provider = new JsonRpcProvider(newUrl, state.chainId); + + // 重置失败状态,给新端点一个全新的 3 分钟窗口 + state.firstFailureAt = null; + state.consecutiveFailures = 0; + } +} diff --git a/backend/services/blockchain-service/src/infrastructure/blockchain/evm-provider.adapter.ts b/backend/services/blockchain-service/src/infrastructure/blockchain/evm-provider.adapter.ts index 4d247786..568e5de5 100644 --- a/backend/services/blockchain-service/src/infrastructure/blockchain/evm-provider.adapter.ts +++ b/backend/services/blockchain-service/src/infrastructure/blockchain/evm-provider.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { JsonRpcProvider, Contract } from 'ethers'; -import { ChainConfigService } from '@/domain/services/chain-config.service'; +import { RpcProviderManager } from '@/domain/services/rpc-provider-manager.service'; import { ChainType, BlockNumber, TokenAmount } from '@/domain/value-objects'; import { ChainTypeEnum } from '@/domain/enums'; @@ -28,53 +28,71 @@ export interface TransferEvent { /** * EVM 区块链提供者适配器 - * 封装与 EVM 链的交互 + * + * 封装与 EVM 链的交互。通过 RpcProviderManager 获取 provider, + * 自动上报 RPC 调用的成功/失败状态,实现故障转移。 */ @Injectable() export class EvmProviderAdapter { private readonly logger = new Logger(EvmProviderAdapter.name); - private readonly providers: Map = new Map(); - constructor(private readonly chainConfig: ChainConfigService) { - this.initializeProviders(); - } - - private initializeProviders(): void { - for (const chainType of this.chainConfig.getSupportedChains()) { - const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType)); - const provider = new JsonRpcProvider(config.rpcUrl, config.chainId); - this.providers.set(chainType, provider); - this.logger.log(`Initialized provider for ${chainType}: ${config.rpcUrl}`); - } - } + constructor(private readonly rpcProviderManager: RpcProviderManager) {} + /** + * 获取某条链当前活跃的 provider(由 RpcProviderManager 统一管理) + */ private getProvider(chainType: ChainType): JsonRpcProvider { - const provider = this.providers.get(chainType.value); - if (!provider) { - throw new Error(`No provider for chain: ${chainType.toString()}`); + return this.rpcProviderManager.getProvider(chainType.value); + } + + /** + * 执行 RPC 调用并自动上报成功/失败状态 + * + * 所有公开方法通过此辅助方法包裹 RPC 调用: + * - 成功时调用 reportSuccess() 重置失败计时 + * - 失败时调用 reportFailure() 记录失败(超过 3 分钟自动切换端点) + * - 错误会 re-throw,不影响调用方的错误处理逻辑 + */ + private async executeWithFailover( + chainType: ChainType, + operation: () => Promise, + ): Promise { + try { + const result = await operation(); + this.rpcProviderManager.reportSuccess(chainType.value); + return result; + } catch (error) { + this.rpcProviderManager.reportFailure( + chainType.value, + error instanceof Error ? error : new Error(String(error)), + ); + throw error; } - return provider; } /** * 获取当前区块号 */ async getCurrentBlockNumber(chainType: ChainType): Promise { - const provider = this.getProvider(chainType); - const blockNumber = await provider.getBlockNumber(); - return BlockNumber.create(blockNumber); + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const blockNumber = await provider.getBlockNumber(); + return BlockNumber.create(blockNumber); + }); } /** * 获取区块时间戳 */ async getBlockTimestamp(chainType: ChainType, blockNumber: BlockNumber): Promise { - const provider = this.getProvider(chainType); - const block = await provider.getBlock(blockNumber.asNumber); - if (!block) { - throw new Error(`Block not found: ${blockNumber.toString()}`); - } - return new Date(block.timestamp * 1000); + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const block = await provider.getBlock(blockNumber.asNumber); + if (!block) { + throw new Error(`Block not found: ${blockNumber.toString()}`); + } + return new Date(block.timestamp * 1000); + }); } /** @@ -86,38 +104,40 @@ export class EvmProviderAdapter { toBlock: BlockNumber, tokenContract: string, ): Promise { - const provider = this.getProvider(chainType); - const contract = new Contract(tokenContract, ERC20_TRANSFER_EVENT_ABI, provider); + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const contract = new Contract(tokenContract, ERC20_TRANSFER_EVENT_ABI, provider); - const filter = contract.filters.Transfer(); - const logs = await contract.queryFilter(filter, fromBlock.asNumber, toBlock.asNumber); + const filter = contract.filters.Transfer(); + const logs = await contract.queryFilter(filter, fromBlock.asNumber, toBlock.asNumber); - const events: TransferEvent[] = []; + const events: TransferEvent[] = []; - for (const log of logs) { - const block = await provider.getBlock(log.blockNumber); - if (!block) continue; + for (const log of logs) { + const block = await provider.getBlock(log.blockNumber); + if (!block) continue; - const parsedLog = contract.interface.parseLog({ - topics: log.topics as string[], - data: log.data, - }); - - if (parsedLog) { - events.push({ - txHash: log.transactionHash, - logIndex: log.index, - blockNumber: BigInt(log.blockNumber), - blockTimestamp: new Date(block.timestamp * 1000), - from: parsedLog.args[0], - to: parsedLog.args[1], - value: parsedLog.args[2], - tokenContract, + const parsedLog = contract.interface.parseLog({ + topics: log.topics as string[], + data: log.data, }); - } - } - return events; + if (parsedLog) { + events.push({ + txHash: log.transactionHash, + logIndex: log.index, + blockNumber: BigInt(log.blockNumber), + blockTimestamp: new Date(block.timestamp * 1000), + from: parsedLog.args[0], + to: parsedLog.args[1], + value: parsedLog.args[2], + tokenContract, + }); + } + } + + return events; + }); } /** @@ -128,42 +148,50 @@ export class EvmProviderAdapter { tokenContract: string, address: string, ): Promise { - const provider = this.getProvider(chainType); - const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider); - const [balance, decimals] = await Promise.all([ - contract.balanceOf(address), - contract.decimals(), - ]); - return TokenAmount.fromRaw(balance, Number(decimals)); + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider); + const [balance, decimals] = await Promise.all([ + contract.balanceOf(address), + contract.decimals(), + ]); + return TokenAmount.fromRaw(balance, Number(decimals)); + }); } /** * 查询 ERC20 代币的 decimals */ async getTokenDecimals(chainType: ChainType, tokenContract: string): Promise { - const provider = this.getProvider(chainType); - const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider); - const decimals = await contract.decimals(); - return Number(decimals); + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider); + const decimals = await contract.decimals(); + return Number(decimals); + }); } /** * 查询原生代币余额 */ async getNativeBalance(chainType: ChainType, address: string): Promise { - const provider = this.getProvider(chainType); - const balance = await provider.getBalance(address); - return TokenAmount.fromRaw(balance, 18); + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const balance = await provider.getBalance(address); + return TokenAmount.fromRaw(balance, 18); + }); } /** * 广播签名交易 */ async broadcastTransaction(chainType: ChainType, signedTx: string): Promise { - const provider = this.getProvider(chainType); - const txResponse = await provider.broadcastTransaction(signedTx); - this.logger.log(`Transaction broadcasted: ${txResponse.hash}`); - return txResponse.hash; + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const txResponse = await provider.broadcastTransaction(signedTx); + this.logger.log(`Transaction broadcasted: ${txResponse.hash}`); + return txResponse.hash; + }); } /** @@ -174,9 +202,11 @@ export class EvmProviderAdapter { txHash: string, confirmations: number = 1, ): Promise { - const provider = this.getProvider(chainType); - const receipt = await provider.waitForTransaction(txHash, confirmations); - return receipt !== null && receipt.status === 1; + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const receipt = await provider.waitForTransaction(txHash, confirmations); + return receipt !== null && receipt.status === 1; + }); } /** @@ -187,12 +217,14 @@ export class EvmProviderAdapter { txHash: string, requiredConfirmations: number, ): Promise { - const provider = this.getProvider(chainType); - const receipt = await provider.getTransactionReceipt(txHash); - if (!receipt) return false; + return this.executeWithFailover(chainType, async () => { + const provider = this.getProvider(chainType); + const receipt = await provider.getTransactionReceipt(txHash); + if (!receipt) return false; - const currentBlock = await provider.getBlockNumber(); - const confirmations = currentBlock - receipt.blockNumber; - return confirmations >= requiredConfirmations; + const currentBlock = await provider.getBlockNumber(); + const confirmations = currentBlock - receipt.blockNumber; + return confirmations >= requiredConfirmations; + }); } } diff --git a/backend/services/docker-compose.yml b/backend/services/docker-compose.yml index b47d6016..d6179889 100644 --- a/backend/services/docker-compose.yml +++ b/backend/services/docker-compose.yml @@ -736,6 +736,8 @@ services: - NETWORK_MODE=mainnet # 主网配置 - dUSDT (绿积分) 合约 - KAVA_RPC_URL=https://evm.kava.io + # RPC 故障转移:逗号分隔的多个端点,主端点失败 3 分钟后自动切换 + - KAVA_RPC_URLS=${KAVA_RPC_URLS:-https://evm.kava.io,https://evm.kava-rpc.com,https://kava-evm-rpc.publicnode.com,https://rpc.ankr.com/kava_evm} - KAVA_CHAIN_ID=2222 - KAVA_USDT_CONTRACT=0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3 # BSC 主网 (暂未使用)