diff --git a/.env.example b/.env.example index 8fe969f7f..3f2525b0f 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,15 @@ # .env.local 文件不会被提交到 git # ==================== API Keys ==================== +# 支持逗号分隔多个 API Key,应用启动时随机选中一个并锁定(会话期间不变) # TronGrid API Key(用于高频率 Tron 请求) # 申请地址: https://www.trongrid.io/ -# VITE_TRONGRID_API_KEY="" +# TRONGRID_API_KEY="uuid1,uuid2,uuid3" + +# Etherscan API Key(用于 EVM 链交易历史查询) +# 申请地址: https://etherscan.io/myapikey +# ETHERSCAN_API_KEY="key1,key2,key3" # ==================== E2E 测试 ==================== diff --git a/.storybook/main.ts b/.storybook/main.ts index d5a2866ff..abcf08675 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from '@storybook/react-vite' +import { loadEnv } from 'vite' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,6 +11,8 @@ const config: StorybookConfig = { options: {}, }, viteFinal: async (config) => { + const env = loadEnv(config.mode ?? 'development', process.cwd(), '') + // Keep Storybook deterministic: use mock exchange rates instead of network calls. config.resolve ??= {} const storybookDir = dirname(fileURLToPath(import.meta.url)) @@ -23,6 +26,18 @@ const config: StorybookConfig = { '#currency-exchange-impl': replacement, } } + + // Inject API keys for dynamic access + const tronGridApiKey = env.TRONGRID_API_KEY ?? process.env.TRONGRID_API_KEY ?? '' + const etherscanApiKey = env.ETHERSCAN_API_KEY ?? process.env.ETHERSCAN_API_KEY ?? '' + config.define = { + ...config.define, + '__API_KEYS__': JSON.stringify({ + TRONGRID_API_KEY: tronGridApiKey, + ETHERSCAN_API_KEY: etherscanApiKey, + }), + } + return config }, } diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 4257c0eef..d4c3256cb 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -143,6 +143,11 @@ "apis": [ { "type": "blockscout-v1", "endpoint": "https://eth.blockscout.com/api" }, { "type": "ethereum-rpc", "endpoint": "https://ethereum-rpc.publicnode.com" }, + { + "type": "etherscan-v2", + "endpoint": "https://api.etherscan.io/v2/api", + "config": { "apiKeyEnv": "ETHERSCAN_API_KEY" } + }, { "type": "ethwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/eth" } ], "explorer": { @@ -166,6 +171,11 @@ "decimals": 18, "apis": [ { "type": "bsc-rpc", "endpoint": "https://bsc-rpc.publicnode.com" }, + { + "type": "etherscan-v2", + "endpoint": "https://api.etherscan.io/v2/api", + "config": { "apiKeyEnv": "ETHERSCAN_API_KEY" } + }, { "type": "bscwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" } ], "explorer": { @@ -192,7 +202,7 @@ { "type": "tron-rpc-pro", "endpoint": "https://api.trongrid.io", - "config": { "apiKeyEnv": "VITE_TRONGRID_API_KEY" } + "config": { "apiKeyEnv": "TRONGRID_API_KEY" } }, { "type": "tronwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/tron" } ], diff --git a/scripts/set-secret.ts b/scripts/set-secret.ts index 06f02a267..fdb1f6255 100644 --- a/scripts/set-secret.ts +++ b/scripts/set-secret.ts @@ -115,14 +115,35 @@ const SECRET_DEFINITIONS: SecretDefinition[] = [ // API Keys { - key: 'VITE_TRONGRID_API_KEY', - description: 'TronGrid API Key(用于高频率 Tron 请求)', + key: 'TRONGRID_API_KEY', + description: 'TronGrid API Key(支持逗号分隔多个,启动时随机选中一个)', category: 'api-keys', required: false, isPassword: true, validate: (v) => { - if (v && !/^[a-f0-9-]{36}$/i.test(v)) { - return 'API Key 格式无效(应为 UUID)' + if (!v) return true + const keys = v.split(',').map(k => k.trim()).filter(Boolean) + for (const key of keys) { + if (!/^[a-f0-9-]{36}$/i.test(key)) { + return `API Key 格式无效: ${key}(应为 UUID)` + } + } + return true + }, + }, + { + key: 'ETHERSCAN_API_KEY', + description: 'Etherscan API Key(支持逗号分隔多个,启动时随机选中一个)', + category: 'api-keys', + required: false, + isPassword: true, + validate: (v) => { + if (!v) return true + const keys = v.split(',').map(k => k.trim()).filter(Boolean) + for (const key of keys) { + if (!/^[A-Z0-9]{34}$/i.test(key)) { + return `API Key 格式无效: ${key}(应为 34 位字母数字)` + } } return true }, diff --git a/src/components/wallet/wallet-address-portfolio.real.test.ts b/src/components/wallet/wallet-address-portfolio.real.test.ts index c283a328e..7d7ed0630 100644 --- a/src/components/wallet/wallet-address-portfolio.real.test.ts +++ b/src/components/wallet/wallet-address-portfolio.real.test.ts @@ -68,7 +68,7 @@ describe.skipIf(!process.env.TEST_REAL_API)("WalletAddressPortfolio Real Provide source: "default", apis: [ { type: "tron-rpc", endpoint: "https://api.trongrid.io" }, - { type: "tron-rpc-pro", endpoint: "https://api.trongrid.io", config: { apiKeyEnv: "VITE_TRONGRID_API_KEY" } }, + { type: "tron-rpc-pro", endpoint: "https://api.trongrid.io", config: { apiKeyEnv: "TRONGRID_API_KEY" } }, { type: "tronwallet-v1", endpoint: "https://walletapi.bfmeta.info/wallet/tron" }, ], }, diff --git a/src/components/wallet/wallet-address-portfolio.stories.tsx b/src/components/wallet/wallet-address-portfolio.stories.tsx index e818eae4a..c6eadffa6 100644 --- a/src/components/wallet/wallet-address-portfolio.stories.tsx +++ b/src/components/wallet/wallet-address-portfolio.stories.tsx @@ -216,221 +216,98 @@ function ChainConfigProvider({ children }: { children: React.ReactNode }) { return <>{children}; } -const REAL_ADDRESSES = { +const REAL_ADDRESSES: Record = { ethereum: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', tron: 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', bitcoin: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', binance: '0x8894E0a0c962CB723c1976a4421c95949bE2D4E3', -} as const; - -const COMPARE_CHAIN_CONFIGS: ChainConfig[] = [ - { - id: 'ethereum-blockscout-only', - version: '1.0', - chainKind: 'evm', - name: 'Ethereum (blockscout only)', - symbol: 'ETH', - icon: '../icons/ethereum/chain.svg', - decimals: 18, - enabled: true, - source: 'manual', - apis: [{ type: 'blockscout-v1', endpoint: 'https://eth.blockscout.com/api' }], - }, - { - id: 'ethereum-rpc-only', - version: '1.0', - chainKind: 'evm', - name: 'Ethereum (rpc only)', - symbol: 'ETH', - icon: '../icons/ethereum/chain.svg', - decimals: 18, - enabled: true, - source: 'manual', - apis: [{ type: 'ethereum-rpc', endpoint: 'https://ethereum-rpc.publicnode.com' }], - }, - { - id: 'ethereum-ethwallet-only', - version: '1.0', - chainKind: 'evm', - name: 'Ethereum (ethwallet only)', - symbol: 'ETH', - icon: '../icons/ethereum/chain.svg', - decimals: 18, - enabled: true, - source: 'manual', - apis: [{ type: 'ethwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/eth' }], - }, - - { - id: 'tron-rpc-only', - version: '1.0', - chainKind: 'tron', - name: 'Tron (rpc only)', - symbol: 'TRX', - icon: '../icons/tron/chain.svg', - decimals: 6, - enabled: true, - source: 'manual', - apis: [{ type: 'tron-rpc', endpoint: 'https://api.trongrid.io' }], - }, - { - id: 'tron-rpc-pro-only', - version: '1.0', - chainKind: 'tron', - name: 'Tron (rpc-pro only)', - symbol: 'TRX', - icon: '../icons/tron/chain.svg', - decimals: 6, - enabled: true, - source: 'manual', - apis: [{ type: 'tron-rpc-pro', endpoint: 'https://api.trongrid.io', config: { apiKeyEnv: 'VITE_TRONGRID_API_KEY' } }], - }, - { - id: 'tron-tronwallet-only', - version: '1.0', - chainKind: 'tron', - name: 'Tron (tronwallet only)', - symbol: 'TRX', - icon: '../icons/tron/chain.svg', - decimals: 6, - enabled: true, - source: 'manual', - apis: [{ type: 'tronwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/tron' }], - }, + bfmeta: 'bCfAynSAKhzgKLi3BXyuh5k22GctLR72j', +}; - { - id: 'bitcoin-mempool-only', - version: '1.0', - chainKind: 'bitcoin', - name: 'Bitcoin (mempool only)', - symbol: 'BTC', - icon: '../icons/bitcoin/chain.svg', - decimals: 8, - enabled: true, - source: 'manual', - apis: [{ type: 'mempool-v1', endpoint: 'https://mempool.space/api' }], - }, - { - id: 'bitcoin-btcwallet-only', - version: '1.0', - chainKind: 'bitcoin', - name: 'Bitcoin (btcwallet only)', - symbol: 'BTC', - icon: '../icons/bitcoin/chain.svg', - decimals: 8, - enabled: true, - source: 'manual', - apis: [{ type: 'btcwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/btc/blockbook' }], - }, +function formatApiLabel(api: ChainConfig['apis'][number]): string { + const apiKeyEnv = api.config && 'apiKeyEnv' in api.config ? (api.config.apiKeyEnv as string | undefined) : undefined; + if (apiKeyEnv) return `${api.type} (${apiKeyEnv})`; + return api.type; +} - { - id: 'binance-bsc-rpc-only', - version: '1.0', - chainKind: 'evm', - name: 'BNB Smart Chain (rpc only)', - symbol: 'BNB', - icon: '../icons/binance/chain.svg', - decimals: 18, +function generateSingleApiConfigs(baseConfig: ChainConfig): ChainConfig[] { + return baseConfig.apis.map((api, index) => ({ + ...baseConfig, + id: `${baseConfig.id}-api-${index}-${api.type}`, + name: `${baseConfig.name} (${api.type})`, + apis: [api], enabled: true, - source: 'manual', - apis: [{ type: 'bsc-rpc', endpoint: 'https://bsc-rpc.publicnode.com' }], - }, - { - id: 'binance-bscwallet-only', - version: '1.0', - chainKind: 'evm', - name: 'BNB Smart Chain (bscwallet only)', - symbol: 'BNB', - icon: '../icons/binance/chain.svg', - decimals: 18, - enabled: true, - source: 'manual', - apis: [{ type: 'bscwallet-v1', endpoint: 'https://walletapi.bfmeta.info/wallet/bsc' }], - }, -]; - -function mergeConfigsById(existing: ChainConfig[], injected: ChainConfig[]): ChainConfig[] { - const byId = new Map(); - for (const config of existing) { - byId.set(config.id, config); - } - for (const config of injected) { - byId.set(config.id, config); - } - return [...byId.values()]; + source: 'manual' as const, + })); } -function CompareConfigInjector() { +function DynamicCompareConfigInjector({ chainId }: { chainId: string }) { const state = useChainConfigState(); const [injected, setInjected] = useState(false); useEffect(() => { if (!state.snapshot) return; if (injected) return; - const alreadyInjected = state.snapshot.configs.some((c) => c.id === 'ethereum-blockscout-only'); + + const baseConfig = state.snapshot.configs.find((c) => c.id === chainId); + if (!baseConfig) return; + + const alreadyInjected = state.snapshot.configs.some((c) => c.id === `${chainId}-api-0-${baseConfig.apis[0]?.type}`); if (alreadyInjected) { setInjected(true); return; } + const singleApiConfigs = generateSingleApiConfigs(baseConfig); + chainConfigStore.setState((prev) => { if (!prev.snapshot) return prev; return { ...prev, snapshot: { ...prev.snapshot, - configs: mergeConfigsById(prev.snapshot.configs, COMPARE_CHAIN_CONFIGS), + configs: [...prev.snapshot.configs, ...singleApiConfigs], }, }; }); clearProviderCache(); setInjected(true); - }, [state.snapshot, injected]); + }, [state.snapshot, injected, chainId]); return null; } -function formatApiLabel(api: ChainConfig['apis'][number]): string { - const apiKeyEnv = api.config && 'apiKeyEnv' in api.config ? (api.config.apiKeyEnv as string | undefined) : undefined; - if (apiKeyEnv) return `${api.type} (apiKeyEnv=${apiKeyEnv})`; - return api.type; -} - -function ProviderPanel({ - title, - chainId, +function DynamicProviderPanel({ + api, + baseConfig, + index, address, - chainName, - testId, }: { - title: string; - chainId: ChainConfig['id']; + api: ChainConfig['apis'][number]; + baseConfig: ChainConfig; + index: number; address: string; - chainName: string; - testId: string; }) { - const config = COMPARE_CHAIN_CONFIGS.find((c) => c.id === chainId); - const apiTypes = config?.apis?.map(formatApiLabel) ?? []; - const primary = apiTypes[0] ?? 'unknown'; - const apiSummary = apiTypes.length ? apiTypes.join(' → ') : 'unknown'; + const dynamicChainId = `${baseConfig.id}-api-${index}-${api.type}`; + const testId = `cmp-${baseConfig.id}-${api.type}`; return (
-

{title}

-

Primary: {primary}

+

{api.type}

+

{api.endpoint}

-

{chainId}

+

#{index}

-

apis[]: {apiSummary}

+

{formatApiLabel(api)}

@@ -438,6 +315,40 @@ function ProviderPanel({ ); } +function DynamicCompareProvidersGrid({ chainId }: { chainId: string }) { + const state = useChainConfigState(); + const baseConfig = state.snapshot?.configs.find((c) => c.id === chainId); + + if (!baseConfig) { + return
Chain config not found: {chainId}
; + } + + const address = REAL_ADDRESSES[chainId] ?? ''; + + return ( +
+
+

{baseConfig.name}

+

+ Comparing {baseConfig.apis.length} provider(s) from default-chains.json +

+

{address}

+
+
+ {baseConfig.apis.map((api, index) => ( + + ))} +
+
+ ); +} + const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -458,10 +369,10 @@ const withChainConfig: DecoratorFunction = (Story) => ( ); -const withCompareChainConfig: DecoratorFunction = (Story) => ( +const createCompareDecorator = (chainId: string): DecoratorFunction => (Story) => ( - +
@@ -800,162 +711,74 @@ export const RealDataBinance: Story = { }, }; -export const CompareProviders: Story = { - name: 'Compare Providers (Real Data)', - decorators: [withCompareChainConfig], +export const CompareProvidersBfmeta: Story = { + name: 'Compare Providers: BFMeta', + decorators: [createCompareDecorator('bfmeta')], parameters: { chromatic: { delay: 8000 }, docs: { description: { - story: 'Side-by-side comparison: each panel uses a single provider (single-item apis[]), so you can compare results provider-by-provider.', + story: 'Dynamically compares all providers configured in default-chains.json for BFMeta chain.', }, }, }, - render: () => ( -
-
-

Ethereum

-
- - - -
-
- -
-

Tron

-
- - - -
-
- -
-

Bitcoin

-
- - -
-
- -
-

BNB Smart Chain

-
- - -
-
-
- ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Storybook's Vitest runner has a default 15s timeout and runs in MODE=test. - // Keep this story lightweight in automated Storybook tests; enforce real-network assertions in Storybook UI. - if (import.meta.env.MODE === 'test') { - await waitFor( - () => { - expect(canvas.getByTestId('cmp-ethereum-blockscout')).toBeVisible(); - expect(canvas.getByTestId('cmp-ethereum-rpc')).toBeVisible(); - expect(canvas.getByTestId('cmp-ethereum-ethwallet')).toBeVisible(); - expect(canvas.getByTestId('cmp-tron-rpc')).toBeVisible(); - expect(canvas.getByTestId('cmp-tron-rpc-pro')).toBeVisible(); - expect(canvas.getByTestId('cmp-tron-tronwallet')).toBeVisible(); - expect(canvas.getByTestId('cmp-bitcoin-mempool')).toBeVisible(); - expect(canvas.getByTestId('cmp-bitcoin-btcwallet')).toBeVisible(); - expect(canvas.getByTestId('cmp-binance-rpc')).toBeVisible(); - expect(canvas.getByTestId('cmp-binance-bscwallet')).toBeVisible(); - }, - { timeout: 10_000 }, - ); - return; - } + render: () => , +}; - await expectAnyProviderPanelOk({ - canvas, - label: 'ethereum', - testIds: ['cmp-ethereum-blockscout', 'cmp-ethereum-ethwallet'], - }); +export const CompareProvidersEthereum: Story = { + name: 'Compare Providers: Ethereum', + decorators: [createCompareDecorator('ethereum')], + parameters: { + chromatic: { delay: 8000 }, + docs: { + description: { + story: 'Dynamically compares all providers configured in default-chains.json for Ethereum chain.', + }, + }, + }, + render: () => , +}; - await expectAnyProviderPanelOk({ - canvas, - label: 'tron', - testIds: ['cmp-tron-rpc', 'cmp-tron-rpc-pro', 'cmp-tron-tronwallet'], - }); +export const CompareProvidersTron: Story = { + name: 'Compare Providers: Tron', + decorators: [createCompareDecorator('tron')], + parameters: { + chromatic: { delay: 8000 }, + docs: { + description: { + story: 'Dynamically compares all providers configured in default-chains.json for Tron chain.', + }, + }, + }, + render: () => , +}; - await expectAnyProviderPanelOk({ - canvas, - label: 'bitcoin', - testIds: ['cmp-bitcoin-mempool', 'cmp-bitcoin-btcwallet'], - }); +export const CompareProvidersBitcoin: Story = { + name: 'Compare Providers: Bitcoin', + decorators: [createCompareDecorator('bitcoin')], + parameters: { + chromatic: { delay: 8000 }, + docs: { + description: { + story: 'Dynamically compares all providers configured in default-chains.json for Bitcoin chain.', + }, + }, + }, + render: () => , +}; - await expectAnyProviderPanelOk({ - canvas, - label: 'binance', - testIds: ['cmp-binance-rpc', 'cmp-binance-bscwallet'], - }); +export const CompareProvidersBinance: Story = { + name: 'Compare Providers: Binance', + decorators: [createCompareDecorator('binance')], + parameters: { + chromatic: { delay: 8000 }, + docs: { + description: { + story: 'Dynamically compares all providers configured in default-chains.json for BNB Smart Chain.', + }, + }, }, + render: () => , }; /** diff --git a/src/services/chain-adapter/evm/transaction-service.ts b/src/services/chain-adapter/evm/transaction-service.ts index bf7d2789e..3b0b6a6c3 100644 --- a/src/services/chain-adapter/evm/transaction-service.ts +++ b/src/services/chain-adapter/evm/transaction-service.ts @@ -301,7 +301,15 @@ export class EvmTransactionService implements ITransactionService { } } + /** EVM JSON-RPC 不支持交易历史查询 */ + readonly supportsTransactionHistory = false + async getTransactionHistory(_address: string, _limit = 20): Promise { - return [] + // 标准 Ethereum JSON-RPC 没有交易历史查询 API + // 需要使用 Etherscan/Blockscout 等区块浏览器 API + throw new ChainServiceError( + ChainErrorCodes.NOT_SUPPORTED, + 'Transaction history not supported by standard EVM JSON-RPC' + ) } } diff --git a/src/services/chain-adapter/providers/__tests__/api-key-picker.test.ts b/src/services/chain-adapter/providers/__tests__/api-key-picker.test.ts new file mode 100644 index 000000000..f9a4da647 --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/api-key-picker.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { pickApiKey, clearApiKeyCache, getLockedApiKey } from '../api-key-picker' + +describe('pickApiKey', () => { + beforeEach(() => { + clearApiKeyCache() + }) + + it('returns undefined for empty string', () => { + expect(pickApiKey('', 'test')).toBeUndefined() + expect(pickApiKey(' ', 'test')).toBeUndefined() + }) + + it('returns undefined for undefined input', () => { + expect(pickApiKey(undefined, 'test')).toBeUndefined() + }) + + it('returns the single key when only one provided', () => { + const result = pickApiKey('single-key', 'test') + expect(result).toBe('single-key') + }) + + it('returns one of the keys when multiple provided', () => { + const keys = 'key1,key2,key3' + const result = pickApiKey(keys, 'test') + expect(['key1', 'key2', 'key3']).toContain(result) + }) + + it('trims whitespace from keys', () => { + const keys = ' key1 , key2 , key3 ' + const result = pickApiKey(keys, 'test') + expect(['key1', 'key2', 'key3']).toContain(result) + }) + + it('filters out empty keys', () => { + const keys = 'key1,,key2, ,key3' + const result = pickApiKey(keys, 'test') + expect(['key1', 'key2', 'key3']).toContain(result) + }) + + it('locks the selected key for subsequent calls', () => { + const keys = 'key1,key2,key3' + const first = pickApiKey(keys, 'cache-test') + const second = pickApiKey(keys, 'cache-test') + const third = pickApiKey(keys, 'cache-test') + + expect(first).toBe(second) + expect(second).toBe(third) + }) + + it('uses different keys for different cache keys', () => { + const result1 = pickApiKey('only-one', 'service-a') + const result2 = pickApiKey('another-one', 'service-b') + + expect(result1).toBe('only-one') + expect(result2).toBe('another-one') + }) + + it('getLockedApiKey returns the cached key', () => { + pickApiKey('my-key', 'get-test') + expect(getLockedApiKey('get-test')).toBe('my-key') + }) + + it('getLockedApiKey returns undefined for uncached key', () => { + expect(getLockedApiKey('nonexistent')).toBeUndefined() + }) + + it('clearApiKeyCache clears all cached keys', () => { + pickApiKey('key1', 'cache1') + pickApiKey('key2', 'cache2') + + clearApiKeyCache() + + expect(getLockedApiKey('cache1')).toBeUndefined() + expect(getLockedApiKey('cache2')).toBeUndefined() + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/provider-capabilities.test.ts b/src/services/chain-adapter/providers/__tests__/provider-capabilities.test.ts new file mode 100644 index 000000000..f8d14ec14 --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/provider-capabilities.test.ts @@ -0,0 +1,310 @@ +/** + * Provider 能力测试 + * + * 验证各 provider 的能力声明与实际行为一致 + * + * 核心原则: + * 1. supportsXxx 返回 true 时,调用对应方法应该能返回有意义的数据 + * 2. supportsXxx 返回 false 时,UI 应显示"不支持"而非"暂无数据" + * 3. API 错误(如 429)应该传递到 UI 显示,而非静默失败 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createChainProvider, clearProviderCache } from '../index' +import { isSupported } from '../types' +import type { ChainConfig } from '@/services/chain-config' + +const mockGetChainById = vi.fn() +vi.mock('@/stores/chain-config', () => ({ + chainConfigStore: { + state: {}, + }, + chainConfigSelectors: { + getChainById: () => mockGetChainById(), + }, +})) + +describe('Provider 能力测试', () => { + beforeEach(() => { + vi.clearAllMocks() + clearProviderCache() + }) + + describe('blockscout-v1 provider', () => { + const BLOCKSCOUT_CONFIG: ChainConfig = { + id: 'ethereum-blockscout-only', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum (blockscout only)', + symbol: 'ETH', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'blockscout-v1', endpoint: 'https://eth.blockscout.com/api' }], + } + + it('should support getNativeBalance', () => { + mockGetChainById.mockReturnValue(BLOCKSCOUT_CONFIG) + const provider = createChainProvider('ethereum-blockscout-only') + + expect(provider.supportsNativeBalance).toBe(true) + }) + + it('should support getTransactionHistory', () => { + mockGetChainById.mockReturnValue(BLOCKSCOUT_CONFIG) + const provider = createChainProvider('ethereum-blockscout-only') + + expect(provider.supportsTransactionHistory).toBe(true) + }) + }) + + describe('ethereum-rpc provider', () => { + const RPC_CONFIG: ChainConfig = { + id: 'ethereum-rpc-only', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum (rpc only)', + symbol: 'ETH', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'ethereum-rpc', endpoint: 'https://ethereum-rpc.publicnode.com' }], + } + + it('should support getNativeBalance', () => { + mockGetChainById.mockReturnValue(RPC_CONFIG) + const provider = createChainProvider('ethereum-rpc-only') + + expect(provider.supportsNativeBalance).toBe(true) + }) + + it('should NOT support getTransactionHistory (RPC has no standard history API)', () => { + mockGetChainById.mockReturnValue(RPC_CONFIG) + const provider = createChainProvider('ethereum-rpc-only') + + // ethereum-rpc 只支持余额查询,不支持交易历史 + // 标准 Ethereum JSON-RPC 没有交易历史查询 API + expect(provider.supportsTransactionHistory).toBe(false) + }) + + it('should return supported=false when calling getTransactionHistory', async () => { + mockGetChainById.mockReturnValue(RPC_CONFIG) + const provider = createChainProvider('ethereum-rpc-only') + + const result = await provider.getTransactionHistory('0x123', 10) + + // 应该返回 fallback 结果,表明不支持 + expect(isSupported(result)).toBe(false) + // 因为 WrappedTransactionProvider 声明了 supportsTransactionHistory = false, + // 所以没有 candidate,返回 "No provider implements" + expect(result.reason).toMatch(/No provider implements|not supported/i) + }) + }) + + describe('etherscan-v2 provider', () => { + const ETHERSCAN_CONFIG: ChainConfig = { + id: 'ethereum-etherscan-only', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum (etherscan only)', + symbol: 'ETH', + decimals: 18, + enabled: true, + source: 'manual', + apis: [{ type: 'etherscan-v2', endpoint: 'https://api.etherscan.io/v2/api' }], + } + + it('should support getNativeBalance', () => { + mockGetChainById.mockReturnValue(ETHERSCAN_CONFIG) + const provider = createChainProvider('ethereum-etherscan-only') + + expect(provider.supportsNativeBalance).toBe(true) + }) + + it('should support getTransactionHistory', () => { + mockGetChainById.mockReturnValue(ETHERSCAN_CONFIG) + const provider = createChainProvider('ethereum-etherscan-only') + + expect(provider.supportsTransactionHistory).toBe(true) + }) + }) + + describe('tron-rpc provider', () => { + const TRON_RPC_CONFIG: ChainConfig = { + id: 'tron-rpc-only', + version: '1.0', + chainKind: 'tron', + name: 'Tron (rpc only)', + symbol: 'TRX', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ type: 'tron-rpc', endpoint: 'https://api.trongrid.io' }], + } + + it('should support getNativeBalance', () => { + mockGetChainById.mockReturnValue(TRON_RPC_CONFIG) + const provider = createChainProvider('tron-rpc-only') + + expect(provider.supportsNativeBalance).toBe(true) + }) + + it('should support getTransactionHistory', () => { + mockGetChainById.mockReturnValue(TRON_RPC_CONFIG) + const provider = createChainProvider('tron-rpc-only') + + expect(provider.supportsTransactionHistory).toBe(true) + }) + }) + + describe('tron-rpc-pro provider', () => { + const TRON_RPC_PRO_CONFIG: ChainConfig = { + id: 'tron-rpc-pro-only', + version: '1.0', + chainKind: 'tron', + name: 'Tron (rpc-pro only)', + symbol: 'TRX', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ type: 'tron-rpc-pro', endpoint: 'https://api.trongrid.io', config: { apiKeyEnv: 'TRONGRID_API_KEY' } }], + } + + it('should support getNativeBalance', () => { + mockGetChainById.mockReturnValue(TRON_RPC_PRO_CONFIG) + const provider = createChainProvider('tron-rpc-pro-only') + + expect(provider.supportsNativeBalance).toBe(true) + }) + + it('should support getTransactionHistory', () => { + mockGetChainById.mockReturnValue(TRON_RPC_PRO_CONFIG) + const provider = createChainProvider('tron-rpc-pro-only') + + expect(provider.supportsTransactionHistory).toBe(true) + }) + }) +}) + +describe('API 错误处理', () => { + beforeEach(() => { + vi.clearAllMocks() + clearProviderCache() + }) + + it('tron-rpc-pro should include API key in request headers', async () => { + const TRON_PRO_CONFIG: ChainConfig = { + id: 'tron-pro-test', + version: '1.0', + chainKind: 'tron', + name: 'Tron Pro Test', + symbol: 'TRX', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ + type: 'tron-rpc-pro', + endpoint: 'https://api.trongrid.io', + config: { apiKeyEnv: 'TRONGRID_API_KEY' } + }], + } + mockGetChainById.mockReturnValue(TRON_PRO_CONFIG) + + const previousKey = process.env.TRONGRID_API_KEY + process.env.TRONGRID_API_KEY = 'test-trongrid-key' + + // 捕获 fetch 调用 + const originalFetch = global.fetch + let capturedHeaders: Record | null = null + global.fetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + capturedHeaders = Object.fromEntries(new Headers(init?.headers).entries()) + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ balance: 1000000 }), + }) + }) + + try { + const provider = createChainProvider('tron-pro-test') + await provider.getNativeBalance('TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9') + + // 验证 API key 被包含在请求头中(只要有值就行) + expect(capturedHeaders).not.toBeNull() + expect(capturedHeaders!['tron-pro-api-key']).toBeDefined() + expect(capturedHeaders!['tron-pro-api-key'].length).toBeGreaterThan(0) + } finally { + global.fetch = originalFetch + if (previousKey === undefined) { + delete process.env.TRONGRID_API_KEY + } else { + process.env.TRONGRID_API_KEY = previousKey + } + } + }) + + it('should propagate 429 rate limit error in fallback reason for getNativeBalance', async () => { + // 这个测试验证:当 API 返回 429 错误时, + // 应该在 fallbackReason 中包含错误信息,而不是静默返回空数据 + + const TRON_CONFIG: ChainConfig = { + id: 'tron-test', + version: '1.0', + chainKind: 'tron', + name: 'Tron Test', + symbol: 'TRX', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ type: 'tron-rpc', endpoint: 'https://api.trongrid.io' }], + } + mockGetChainById.mockReturnValue(TRON_CONFIG) + + // 模拟 fetch 返回 429 错误 + const originalFetch = global.fetch + global.fetch = vi.fn().mockRejectedValue(new Error('429 Too Many Requests')) + + try { + const provider = createChainProvider('tron-test') + const result = await provider.getNativeBalance('TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9') + + // 应该返回 fallback 结果,并包含错误信息 + expect(isSupported(result)).toBe(false) + expect(result.reason).toContain('429') + } finally { + global.fetch = originalFetch + } + }) + + it('should propagate 429 rate limit error in fallback reason for getTransactionHistory', async () => { + // 这个测试验证:当 API 返回 429 错误时, + // 交易历史应该在 fallbackReason 中包含错误信息 + + const TRON_CONFIG: ChainConfig = { + id: 'tron-test-tx', + version: '1.0', + chainKind: 'tron', + name: 'Tron Test', + symbol: 'TRX', + decimals: 6, + enabled: true, + source: 'manual', + apis: [{ type: 'tron-rpc', endpoint: 'https://api.trongrid.io' }], + } + mockGetChainById.mockReturnValue(TRON_CONFIG) + + // 模拟 fetch 返回 429 错误 + const originalFetch = global.fetch + global.fetch = vi.fn().mockRejectedValue(new Error('429 Too Many Requests')) + + try { + const provider = createChainProvider('tron-test-tx') + const result = await provider.getTransactionHistory('TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9', 10) + + // 应该返回 fallback 结果,并包含错误信息 + expect(isSupported(result)).toBe(false) + expect(result.reason).toContain('429') + } finally { + global.fetch = originalFetch + } + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts b/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts index 166bc9d58..987f8f2ba 100644 --- a/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/tron-rpc-provider.test.ts @@ -65,14 +65,14 @@ describe('TronRpcProvider', () => { describe('apiKeyEnv', () => { it('adds TRON-PRO-API-KEY header when apiKeyEnv is set', async () => { - const previous = process.env.VITE_TRONGRID_API_KEY - process.env.VITE_TRONGRID_API_KEY = 'test-trongrid-key' + const previous = process.env.TRONGRID_API_KEY + process.env.TRONGRID_API_KEY = 'test-trongrid-key' try { const entry: ParsedApiEntry = { type: 'tron-rpc-pro', endpoint: 'https://api.trongrid.io', - config: { apiKeyEnv: 'VITE_TRONGRID_API_KEY' }, + config: { apiKeyEnv: 'TRONGRID_API_KEY' }, } mockFetch.mockImplementationOnce(async (_url: string, init?: RequestInit) => { @@ -89,9 +89,9 @@ describe('TronRpcProvider', () => { expect(height).toBe(1n) } finally { if (previous === undefined) { - delete process.env.VITE_TRONGRID_API_KEY + delete process.env.TRONGRID_API_KEY } else { - process.env.VITE_TRONGRID_API_KEY = previous + process.env.TRONGRID_API_KEY = previous } } }) diff --git a/src/services/chain-adapter/providers/api-key-picker.ts b/src/services/chain-adapter/providers/api-key-picker.ts new file mode 100644 index 000000000..ff0541e6e --- /dev/null +++ b/src/services/chain-adapter/providers/api-key-picker.ts @@ -0,0 +1,62 @@ +/** + * API Key Picker + * + * 支持逗号分隔的多个 API key,应用启动时随机选择一个并锁定。 + * 用于 Etherscan、TronGrid 等需要 API key 的服务。 + */ + +const lockedKeys = new Map() + +/** + * 从逗号分隔的 API key 字符串中随机选择一个 + * + * - 首次调用时随机选择并缓存(应用启动后锁定) + * - 后续调用返回相同的 key + * - 支持格式: "key1" 或 "key1,key2,key3" + * + * @param apiKeyString - 逗号分隔的 API key 字符串 + * @param cacheKey - 缓存键,用于区分不同服务(如 'etherscan', 'trongrid') + * @returns 选中的单个 API key,如果输入为空则返回 undefined + */ +export function pickApiKey(apiKeyString: string | undefined, cacheKey: string): string | undefined { + if (!apiKeyString || apiKeyString.trim() === '') { + return undefined + } + + // 检查缓存 + if (lockedKeys.has(cacheKey)) { + return lockedKeys.get(cacheKey) + } + + // 解析并选择 + const keys = apiKeyString + .split(',') + .map(k => k.trim()) + .filter(k => k.length > 0) + + if (keys.length === 0) { + return undefined + } + + // 随机选择一个 + const selectedKey = keys[Math.floor(Math.random() * keys.length)] + + // 缓存选择结果 + lockedKeys.set(cacheKey, selectedKey) + + return selectedKey +} + +/** + * 清除缓存的 API key(仅用于测试) + */ +export function clearApiKeyCache(): void { + lockedKeys.clear() +} + +/** + * 获取当前缓存的 API key(仅用于调试) + */ +export function getLockedApiKey(cacheKey: string): string | undefined { + return lockedKeys.get(cacheKey) +} diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index e8e24a9d9..b971166e6 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -39,9 +39,34 @@ export class ChainProvider { /** * 检查是否有 Provider 支持某方法 + * + * 除了检查方法是否存在,还会检查 provider 是否有对应的 supportsXxx 属性。 + * 如果 supportsXxx 明确为 false,则认为该 provider 不支持此方法。 */ supports(method: ApiProviderMethod): boolean { - return this.providers.some(p => typeof p[method] === 'function') + return this.getCandidates(method).length > 0 + } + + /** + * 获取真正支持某方法的 Provider 列表 + * + * 过滤掉明确声明不支持的 provider(supportsXxx = false) + */ + private getCandidates(method: ApiProviderMethod): ApiProvider[] { + return this.providers.filter(p => { + if (typeof p[method] !== 'function') return false + + // 检查是否有明确的能力声明 + // 例如: getTransactionHistory -> supportsTransactionHistory + const methodName = method.startsWith('get') ? method.slice(3) : method + const capabilityKey = `supports${methodName.charAt(0).toUpperCase()}${methodName.slice(1)}` as keyof ApiProvider + const capability = p[capabilityKey] + if (typeof capability === 'boolean') { + return capability + } + + return true + }) } /** @@ -221,7 +246,7 @@ export class ChainProvider { * @returns ProviderResult - supported: true 表示查询成功,false 表示 fallback 到默认值 */ async getTransactionHistory(address: string, limit = 20): Promise> { - const candidates = this.providers.filter(p => typeof p.getTransactionHistory === 'function') + const candidates = this.getCandidates('getTransactionHistory') if (candidates.length === 0) { return createFallbackResult( diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-provider.ts index 90b047f7c..0e7316eb1 100644 --- a/src/services/chain-adapter/providers/etherscan-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-provider.ts @@ -11,6 +11,29 @@ import type { ApiProvider, Transaction, Direction, Action } from './types' import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' import { fetchJson } from './fetch-json' +import { pickApiKey } from './api-key-picker' + +function readEnvValue(key: string): string | undefined { + // Node/Vitest 环境:允许在运行时通过 process.env 覆盖(保证测试可控) + try { + const fromProcess = (process as any)?.env?.[key] + if (typeof fromProcess === 'string' && fromProcess.length > 0) { + return fromProcess + } + } catch { + // ignore + } + + // Vite 编译时注入:__API_KEYS__ 是可动态索引的对象字面量 + if (typeof __API_KEYS__ !== 'undefined') { + const apiKey = __API_KEYS__[key] + if (typeof apiKey === 'string' && apiKey.length > 0) { + return apiKey + } + } + + return undefined +} /** EVM Chain IDs */ const EVM_CHAIN_IDS: Record = { @@ -88,6 +111,23 @@ export class EtherscanProvider implements ApiProvider { return true } + private getApiKey(): string | undefined { + // 支持两种 API key 配置方式: + // 1. apiKey: 直接配置(支持逗号分隔多个) + // 2. apiKeyEnv: 从环境变量读取(支持逗号分隔多个) + let apiKeyString: string | undefined + const apiKeyDirect = this.config?.apiKey + if (typeof apiKeyDirect === 'string' && apiKeyDirect.length > 0) { + apiKeyString = apiKeyDirect + } else { + const apiKeyEnv = this.config?.apiKeyEnv + if (typeof apiKeyEnv === 'string' && apiKeyEnv.length > 0) { + apiKeyString = readEnvValue(apiKeyEnv) + } + } + return pickApiKey(apiKeyString, `etherscan:${this.chainId}`) + } + async getNativeBalance(address: string): Promise { const params = new URLSearchParams({ module: 'account', @@ -100,8 +140,8 @@ export class EtherscanProvider implements ApiProvider { params.set('chainid', this.evmChainId.toString()) } - const apiKey = this.config?.apiKey - if (apiKey && typeof apiKey === 'string') { + const apiKey = this.getApiKey() + if (apiKey) { params.set('apikey', apiKey) } @@ -357,7 +397,6 @@ export class EtherscanProvider implements ApiProvider { } private buildParams(action: string, address: string, limit: number): URLSearchParams { - const apiKey = (this.config?.apiKey as string) ?? '' const params = new URLSearchParams({ module: 'account', action, @@ -374,6 +413,7 @@ export class EtherscanProvider implements ApiProvider { params.set('chainid', this.evmChainId.toString()) } + const apiKey = this.getApiKey() if (apiKey) { params.set('apikey', apiKey) } diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index 2cd38e137..6a8b82b26 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -7,6 +7,7 @@ export * from './types' export { ChainProvider } from './chain-provider' export { InvalidDataError } from './errors' +export { pickApiKey, clearApiKeyCache, getLockedApiKey } from './api-key-picker' // API Provider 实现 export { EtherscanProvider, createEtherscanProvider } from './etherscan-provider' diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.ts b/src/services/chain-adapter/providers/tron-rpc-provider.ts index 37548aaef..9754fc5a7 100644 --- a/src/services/chain-adapter/providers/tron-rpc-provider.ts +++ b/src/services/chain-adapter/providers/tron-rpc-provider.ts @@ -10,20 +10,25 @@ import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' import { fetchJson, observeValueAndInvalidate } from './fetch-json' +import { pickApiKey } from './api-key-picker' function readEnvValue(key: string): string | undefined { + // Node/Vitest 环境:允许在运行时通过 process.env 覆盖(保证测试可控) try { - const fromImportMeta = (import.meta as any)?.env?.[key] - if (typeof fromImportMeta === 'string' && fromImportMeta.length > 0) return fromImportMeta + const fromProcess = (process as any)?.env?.[key] + if (typeof fromProcess === 'string' && fromProcess.length > 0) { + return fromProcess + } } catch { // ignore } - try { - const fromProcess = (process as any)?.env?.[key] - if (typeof fromProcess === 'string' && fromProcess.length > 0) return fromProcess - } catch { - // ignore + // Vite 编译时注入:__API_KEYS__ 是可动态索引的对象字面量 + if (typeof __API_KEYS__ !== 'undefined') { + const apiKey = __API_KEYS__[key] + if (typeof apiKey === 'string' && apiKey.length > 0) { + return apiKey + } } return undefined @@ -137,14 +142,25 @@ export class TronRpcProvider implements ApiProvider { headers['Content-Type'] = 'application/json' } - const apiKeyEnv = this.config?.apiKeyEnv - if (typeof apiKeyEnv === 'string' && apiKeyEnv.length > 0) { - const apiKey = readEnvValue(apiKeyEnv) - if (apiKey) { - headers['TRON-PRO-API-KEY'] = apiKey + // 支持两种 API key 配置方式: + // 1. apiKey: 直接配置(支持逗号分隔多个) + // 2. apiKeyEnv: 从环境变量读取(支持逗号分隔多个) + let apiKeyString: string | undefined + const apiKeyDirect = this.config?.apiKey + if (typeof apiKeyDirect === 'string' && apiKeyDirect.length > 0) { + apiKeyString = apiKeyDirect + } else { + const apiKeyEnv = this.config?.apiKeyEnv + if (typeof apiKeyEnv === 'string' && apiKeyEnv.length > 0) { + apiKeyString = readEnvValue(apiKeyEnv) } } + const apiKey = pickApiKey(apiKeyString, `trongrid:${this.chainId}`) + if (apiKey) { + headers['TRON-PRO-API-KEY'] = apiKey + } + const init: RequestInit = body !== undefined ? { method: 'POST', headers, body: JSON.stringify(body) } : { method: 'GET', headers } diff --git a/src/services/chain-adapter/providers/wrapped-transaction-provider.ts b/src/services/chain-adapter/providers/wrapped-transaction-provider.ts index b6f1d9687..70f153f62 100644 --- a/src/services/chain-adapter/providers/wrapped-transaction-provider.ts +++ b/src/services/chain-adapter/providers/wrapped-transaction-provider.ts @@ -35,6 +35,12 @@ export class WrappedTransactionProvider implements ApiProvider { this.type = type } + /** 检查底层服务是否支持交易历史查询 */ + get supportsTransactionHistory(): boolean { + const service = this.transactionService as { supportsTransactionHistory?: boolean } + return service.supportsTransactionHistory !== false + } + async getNativeBalance(address: string): Promise { return this.assetService.getNativeBalance(address) } diff --git a/src/services/chain-adapter/tron/asset-service.ts b/src/services/chain-adapter/tron/asset-service.ts index aae2dd5fe..d76bba4d4 100644 --- a/src/services/chain-adapter/tron/asset-service.ts +++ b/src/services/chain-adapter/tron/asset-service.ts @@ -42,29 +42,23 @@ export class TronAssetService implements IAssetService { } async getNativeBalance(address: Address): Promise { - try { - const account = await this.api>('/wallet/getaccount', { - address, - visible: true, - }) - - if (!account || !('balance' in account)) { - return { - amount: Amount.fromRaw('0', this.decimals, this.symbol), - symbol: this.symbol, - } - } + const account = await this.api>('/wallet/getaccount', { + address, + visible: true, + }) - return { - amount: Amount.fromRaw(account.balance.toString(), this.decimals, this.symbol), - symbol: this.symbol, - } - } catch { + // 空账户或未激活账户返回 0 余额 + if (!account || !('balance' in account)) { return { amount: Amount.fromRaw('0', this.decimals, this.symbol), symbol: this.symbol, } } + + return { + amount: Amount.fromRaw(account.balance.toString(), this.decimals, this.symbol), + symbol: this.symbol, + } } async getTokenBalance(address: Address, tokenAddress: Address): Promise { diff --git a/src/services/chain-adapter/tron/transaction-service.ts b/src/services/chain-adapter/tron/transaction-service.ts index 4c90e1cb0..e537f179e 100644 --- a/src/services/chain-adapter/tron/transaction-service.ts +++ b/src/services/chain-adapter/tron/transaction-service.ts @@ -245,9 +245,15 @@ export class TronTransactionService implements ITransactionService { } } + /** Tron HTTP API 不支持交易历史查询,需要使用 TronGrid API */ + readonly supportsTransactionHistory = false + async getTransactionHistory(_address: string, _limit = 20): Promise { // Tron HTTP API doesn't support transaction history queries // Would need TronGrid API or similar indexer service - return [] + throw new ChainServiceError( + ChainErrorCodes.NOT_SUPPORTED, + 'Transaction history not supported by Tron HTTP API, use TronGrid API instead' + ) } } diff --git a/src/services/chain-adapter/types.ts b/src/services/chain-adapter/types.ts index 60a1ea1db..2542071d1 100644 --- a/src/services/chain-adapter/types.ts +++ b/src/services/chain-adapter/types.ts @@ -204,6 +204,7 @@ export class ChainServiceError extends Error { export const ChainErrorCodes = { // 通用 CHAIN_NOT_SUPPORTED: 'CHAIN_NOT_SUPPORTED', + NOT_SUPPORTED: 'NOT_SUPPORTED', NETWORK_ERROR: 'NETWORK_ERROR', INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', INSUFFICIENT_FEE: 'INSUFFICIENT_FEE', diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index db53b4b14..f39301682 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,12 @@ declare global { /** Mock 模式标识 - 通过 vite.config.ts define 配置 */ const __MOCK_MODE__: boolean + /** Dev 模式标识 - 通过 vite.config.ts define 配置 */ + const __DEV_MODE__: boolean + + /** API Keys 映射 - 通过 vite.config.ts / Storybook viteFinal define 配置 */ + const __API_KEYS__: Record + interface Window { Buffer: typeof Buffer } diff --git a/vite.config.ts b/vite.config.ts index 410f5228f..eb402ed6d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import commonjs from 'vite-plugin-commonjs' @@ -9,21 +9,6 @@ import { mockDevToolsPlugin } from './scripts/vite-plugin-mock-devtools' import { miniappsPlugin } from './scripts/vite-plugin-miniapps' import { buildCheckPlugin } from './scripts/vite-plugin-build-check' -/** - * 服务实现选择(编译时) - * - web: 浏览器环境(默认) - * - dweb: DWEB/Plaoc 平台 - * - mock: 测试环境 - */ -const SERVICE_IMPL = process.env.SERVICE_IMPL ?? 'web' - -/** - * Base URL 配置 - * - 使用 './' 允许部署在任意子路径下 - * - 例如: https://example.com/ 或 https://example.com/app/ - */ -const BASE_URL = process.env.VITE_BASE_URL ?? './' - function getPreferredLanIPv4(): string | undefined { const ifaces = networkInterfaces() const ips: string[] = [] @@ -50,9 +35,30 @@ function getPreferredLanIPv4(): string | undefined { return ips[0] } -const DEV_HOST = process.env.VITE_DEV_HOST ?? getPreferredLanIPv4() +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + /** + * 服务实现选择(编译时) + * - web: 浏览器环境(默认) + * - dweb: DWEB/Plaoc 平台 + * - mock: 测试环境 + */ + const SERVICE_IMPL = env.SERVICE_IMPL ?? process.env.SERVICE_IMPL ?? 'web' + + /** + * Base URL 配置 + * - 使用 './' 允许部署在任意子路径下 + * - 例如: https://example.com/ 或 https://example.com/app/ + */ + const BASE_URL = env.VITE_BASE_URL ?? process.env.VITE_BASE_URL ?? './' -export default defineConfig({ + const DEV_HOST = env.VITE_DEV_HOST ?? process.env.VITE_DEV_HOST ?? getPreferredLanIPv4() + + const tronGridApiKey = env.TRONGRID_API_KEY ?? process.env.TRONGRID_API_KEY ?? '' + const etherscanApiKey = env.ETHERSCAN_API_KEY ?? process.env.ETHERSCAN_API_KEY ?? '' + + return { base: BASE_URL, server: { host: true, @@ -114,7 +120,12 @@ export default defineConfig({ // Mock 模式标识(用于条件加载 MockDevTools) '__MOCK_MODE__': JSON.stringify(SERVICE_IMPL === 'mock'), // Dev 模式标识(用于显示开发版水印) - '__DEV_MODE__': JSON.stringify(process.env.VITE_DEV_MODE === 'true'), + '__DEV_MODE__': JSON.stringify((env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true'), + // API Keys 对象(用于动态读取环境变量) + '__API_KEYS__': JSON.stringify({ + TRONGRID_API_KEY: tronGridApiKey, + ETHERSCAN_API_KEY: etherscanApiKey, + }), }, optimizeDeps: { include: ['buffer'], @@ -173,4 +184,5 @@ export default defineConfig({ }, }, }, +} })