Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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" }
]
```
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
3 changes: 2 additions & 1 deletion src/services/chain-adapter/providers/etherscan-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
35 changes: 20 additions & 15 deletions src/services/chain-adapter/providers/ethwallet-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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<T> => r.success)
.map((r) => r.data)
Expand Down
18 changes: 10 additions & 8 deletions src/services/chain-adapter/providers/tronwallet-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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,
}
}
Expand Down