diff --git "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/01-\346\234\215\345\212\241\346\236\266\346\236\204/api-providers-status.md" "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/01-\346\234\215\345\212\241\346\236\266\346\236\204/api-providers-status.md" new file mode 100644 index 000000000..554daa0db --- /dev/null +++ "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/01-\346\234\215\345\212\241\346\236\266\346\236\204/api-providers-status.md" @@ -0,0 +1,57 @@ +# API Provider 状态与配置 + +> 记录各链 API Provider 的可用性状态、已知问题和配置建议。 +> +> 最后更新: 2026-01-06 (PR #174) + +## Provider 状态概览 + +| 链 | Provider 类型 | 服务商/URL | 状态 | 余额 | 历史 | 说明 | +|---|---|---|---|---|---|---| +| **ETH** | `blockscout-v1` | `eth.blockscout.com` | ✅ 正常 | ✅ | ✅ | 推荐使用,完全免费且稳定 | +| **ETH** | `ethereum-rpc` | `ethereum-rpc.publicnode.com` | ⚠️ 部分 | ✅ | ❌ | RPC 仅支持余额查询,不支持交易历史 | +| **ETH** | `etherscan-v2` | `api.etherscan.io` | ❌ 需鉴权 | ❌ | ❌ | 需要配置 `ETHERSCAN_API_KEY` | +| **ETH** | `ethwallet-v1` | `walletapi.bfmeta.info` | ✅ 正常 | ✅ | ✅ | 自研 API,PR #174 已修复 Schema 问题 | +| **BNB** | `bsc-rpc` | `bsc-rpc.publicnode.com` | ⚠️ 部分 | ✅ | ❌ | RPC 仅支持余额查询 | +| **BSC** | `etherscan-v2` | `api.etherscan.io` | ❌ 需付费 | ❌ | ❌ | BSC 链在 Etherscan V2 中需要付费 Plan | +| **BSC** | `bscwallet-v1` | `walletapi.bfmeta.info` | ⚠️ 部分 | ✅ | ❌ | 交易历史查询返回 `NOTOK`,后端数据问题 | +| **Tron** | `tronwallet-v1` | `walletapi.bfmeta.info` | ✅ 正常 | ✅ | ✅ | PR #174 已修复 Schema 问题 | +| **BTC** | `btcwallet-v1` | `walletapi.bfmeta.info` | ❌ 故障 | ❌ | ❌ | 后端返回 404,建议使用 Mempool | +| **BTC** | `mempool-v1` | `mempool.space` | ✅ 正常 | ✅ | ✅ | 推荐使用的标准 BTC 数据源 | + +## 已知问题与解决方案 + +### 1. BSC WalletAPI 交易历史缺失 +- **现象**: `getTransactionHistory` 返回空数组,API 响应 `{"success":true,"result":{"status":"0","message":"NOTOK","result":[]}}`。 +- **原因**: 即使是活跃地址也无法获取交易,可能是后端 BSC 模块数据同步问题或 BscScan V1 源失效。 +- **建议**: 暂时依赖 RPC 查询余额,交易记录需等待后端修复。 + +### 2. Etherscan V2 鉴权 +- **现象**: 返回 `Missing/Invalid API Key`。 +- **配置**: 需要在环境变量中设置 `ETHERSCAN_API_KEY`。 +- **注意**: BSC 链即使有 Key,普通免费 Plan 也不支持通过统一入口访问,需要使用独立的 BscScan API(V1 已弃用,需迁移到 V2)。 + +### 3. BTC WalletAPI 404 +- **现象**: 所有 `/wallet/btc/*` 路径均返回 404 Not Found。 +- **建议**: 在 `default-chains.json` 中保持 `mempool-v1` 为首选,或移除 `btcwallet-v1` 配置。 + +## 配置建议 + +### 环境变量 +推荐配置以下 API Key 以获得最佳稳定性: + +```bash +# .env.local +ETHERSCAN_API_KEY=your_key_here +TRONGRID_API_KEY=your_key_here +``` + +### 链配置 (default-chains.json) +对于 BTC,确保 Mempool 在前: + +```json +"apis": [ + { "type": "mempool-v1", "endpoint": "https://mempool.space/api" }, + { "type": "btcwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/btc/blockbook" } +] +``` diff --git a/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts index 34e365f77..bbba57e81 100644 --- a/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts @@ -33,7 +33,7 @@ describe('EthWalletProvider', () => { }) it('maps balance string to Amount', async () => { - mockFetch.mockResolvedValue({ ok: true, json: async () => '1000000000000000000' }) + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true, result: '1000000000000000000' }) }) const provider = new EthWalletProvider(entry, 'ethereum') const balance = await provider.getNativeBalance('0xabc') @@ -73,13 +73,13 @@ describe('EthWalletProvider', () => { mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { if (url.endsWith('/trans/normal/history')) { expect(init?.method).toBe('POST') - return { ok: true, json: async () => ({ status: '1', result: [nativeTx] }) } + return { ok: true, json: async () => ({ success: true, result: { status: '1', result: [nativeTx] } }) } } if (url.endsWith('/trans/erc20/history')) { expect(init?.method).toBe('POST') - return { ok: true, json: async () => ({ status: '1', result: [tokenTx] }) } + return { ok: true, json: async () => ({ success: true, result: { status: '1', result: [tokenTx] } }) } } - return { ok: true, json: async () => ({ status: '1', result: [] }) } + return { ok: true, json: async () => ({ success: true, result: { status: '1', result: [] } }) } }) const provider = new EthWalletProvider(entry, 'ethereum') diff --git a/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts index f0cb43e1d..38126543e 100644 --- a/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts @@ -62,7 +62,7 @@ describe('TronWalletProvider', () => { mockFetch.mockImplementation(async (url: string) => { expect(url).toContain(`/balance?address=${expectedHex}`) - return { ok: true, json: async () => '1000000' } + return { ok: true, json: async () => ({ success: true, result: '1000000' }) } }) const provider = new TronWalletProvider(entry, 'tron') diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-provider.ts index 0e7316eb1..078d4b1da 100644 --- a/src/services/chain-adapter/providers/etherscan-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-provider.ts @@ -7,9 +7,10 @@ */ import { z } from 'zod' -import type { ApiProvider, Transaction, Direction, Action } from './types' +import type { ApiProvider, Balance, Transaction, Direction, Action } from './types' import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' import { fetchJson } from './fetch-json' import { pickApiKey } from './api-key-picker' diff --git a/src/services/chain-adapter/providers/ethwallet-provider.ts b/src/services/chain-adapter/providers/ethwallet-provider.ts index 467bfce44..6697174e6 100644 --- a/src/services/chain-adapter/providers/ethwallet-provider.ts +++ b/src/services/chain-adapter/providers/ethwallet-provider.ts @@ -5,14 +5,17 @@ import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' import { fetchJson, observeValueAndInvalidate } from './fetch-json' -const BalanceResponseSchema = z.union([ - z.string(), - z.number().transform((v) => String(v)), -]) - -const HistoryResponseSchema = z.looseObject({ - status: z.string().optional(), - result: z.array(z.unknown()), +const WalletApiBalanceSchema = z.looseObject({ + success: z.boolean(), + result: z.union([z.string(), z.number().transform((v) => String(v))]), +}) + +const WalletApiHistorySchema = z.looseObject({ + success: z.boolean(), + result: z.looseObject({ + status: z.string().optional(), + result: z.array(z.unknown()), + }), }) const NativeTxSchema = z.looseObject({ @@ -112,19 +115,21 @@ export class EthWalletProvider implements ApiProvider { ttlMs: 60_000, tags: [`balance:${this.chainId}:${address}`], }) - const parsed = BalanceResponseSchema.safeParse(json) - if (!parsed.success) { + const parsed = WalletApiBalanceSchema.safeParse(json) + if (!parsed.success || !parsed.data.success) { throw new Error('Invalid API response') } + const balanceValue = parsed.data.result + observeValueAndInvalidate({ key: `balance:${this.chainId}:${address}`, - value: parsed.data, + value: balanceValue, invalidateTags: [`txhistory:${this.chainId}:${address}`], }) return { - amount: Amount.fromRaw(parsed.data, this.decimals, this.symbol), + amount: Amount.fromRaw(balanceValue, this.decimals, this.symbol), symbol: this.symbol, } } @@ -264,12 +269,12 @@ export class EthWalletProvider implements ApiProvider { }, ) - const parsed = HistoryResponseSchema.safeParse(json) - if (!parsed.success) { + const parsed = WalletApiHistorySchema.safeParse(json) + if (!parsed.success || !parsed.data.success) { throw new Error('Invalid API response') } - return parsed.data.result + return parsed.data.result.result .map((item) => itemSchema.safeParse(item)) .filter((r): r is z.SafeParseSuccess => r.success) .map((r) => r.data) diff --git a/src/services/chain-adapter/providers/tronwallet-provider.ts b/src/services/chain-adapter/providers/tronwallet-provider.ts index d940f8069..0c6a1a924 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.ts @@ -10,10 +10,10 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' /** Base58 alphabet used by Tron */ const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' -const BalanceResponseSchema = z.union([ - z.string(), - z.number().transform((v) => String(v)), -]) +const WalletApiBalanceSchema = z.looseObject({ + success: z.boolean(), + result: z.union([z.string(), z.number().transform((v) => String(v))]), +}) const TronHistorySchema = z.looseObject({ data: z.array(z.unknown()), @@ -148,19 +148,21 @@ export class TronWalletProvider implements ApiProvider { ttlMs: 60_000, tags: [`balance:${this.chainId}:${address}`], }) - const parsed = BalanceResponseSchema.safeParse(json) - if (!parsed.success) { + const parsed = WalletApiBalanceSchema.safeParse(json) + if (!parsed.success || !parsed.data.success) { throw new Error('Invalid API response') } + const balanceValue = parsed.data.result + observeValueAndInvalidate({ key: `balance:${this.chainId}:${address}`, - value: parsed.data, + value: balanceValue, invalidateTags: [`txhistory:${this.chainId}:${address}`], }) return { - amount: Amount.fromRaw(parsed.data, this.decimals, this.symbol), + amount: Amount.fromRaw(balanceValue, this.decimals, this.symbol), symbol: this.symbol, } }