diff --git a/.env.example b/.env.example index a911ad3ce..8fe969f7f 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,12 @@ # 复制此文件为 .env.local 并填入真实值 # .env.local 文件不会被提交到 git +# ==================== API Keys ==================== + +# TronGrid API Key(用于高频率 Tron 请求) +# 申请地址: https://www.trongrid.io/ +# VITE_TRONGRID_API_KEY="" + # ==================== E2E 测试 ==================== # 资金账号助记词 - 用于提供测试资金(24个中文词,空格分隔) diff --git "a/docs/white-book/\351\231\204\345\275\225/I-\345\244\226\351\203\250\346\234\215\345\212\241\346\217\220\344\276\233\345\225\206/index.md" "b/docs/white-book/\351\231\204\345\275\225/I-\345\244\226\351\203\250\346\234\215\345\212\241\346\217\220\344\276\233\345\225\206/index.md" index 001fecf42..951442d02 100644 --- "a/docs/white-book/\351\231\204\345\275\225/I-\345\244\226\351\203\250\346\234\215\345\212\241\346\217\220\344\276\233\345\225\206/index.md" +++ "b/docs/white-book/\351\231\204\345\275\225/I-\345\244\226\351\203\250\346\234\215\345\212\241\346\217\220\344\276\233\345\225\206/index.md" @@ -12,6 +12,26 @@ --- +## KeyApp 配置与 Provider(重要) + +KeyApp 的链配置(`public/configs/*.json`)使用有序数组 `apis[]` 描述外部 Provider。 + +- 字段:`{ type, endpoint, config? }` +- 顺序:**从前到后**为优先级,前一个失败会自动 fallback 到下一个 +- 破坏性变更:历史的 `api`(对象/字典)已废弃,统一使用 `apis[]` + +常见 Provider 类型(示例): + +- EVM:`blockscout-v1` / `*-rpc` / `ethwallet-v1` / `bscwallet-v1` +- Tron:`tron-rpc` / `tron-rpc-pro` / `tronwallet-v1` +- Bitcoin:`mempool-v1` / `btcwallet-v1` + +TronGrid Key: + +- `tron-rpc-pro` 支持在 `config.apiKeyEnv` 指定环境变量(如 `VITE_TRONGRID_API_KEY`),用于注入请求头 `TRON-PRO-API-KEY` + +--- + ## 链类型分类 | 类型 | 链 | API 路径前缀 | 特点 | @@ -112,6 +132,11 @@ src/apis/ | POST | `/wallet/eth/account/balance/v2` | 批量查余额 | | GET | `/wallet/eth/getChainId` | 获取 chainId | +KeyApp Provider 对应关系: + +- `ethwallet-v1` 以 `/wallet/eth/*` 为前缀 +- `bscwallet-v1` 以 `/wallet/bsc/*` 为前缀 + --- ## 三、比特币 (bitcoin) @@ -137,6 +162,10 @@ src/apis/ **注意**: Bitcoin API 使用代理模式,通过 POST 发送 `{ url, method }` 参数。 +KeyApp Provider 对应关系: + +- `btcwallet-v1` 对应 `/wallet/btc/blockbook`(代理 blockbook) + --- ## 四、波场 (tron) @@ -170,6 +199,10 @@ src/apis/ | POST | `/wallet/tron/contract/token/detail` | 代币详情 | | POST | `/wallet/tron/account/balance/v2` | 批量查余额 | +KeyApp Provider 对应关系: + +- `tronwallet-v1` 以 `/wallet/tron/*` 为前缀 + --- ## 响应格式 diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index bc02f29ea..4257c0eef 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -15,9 +15,7 @@ ], "prefix": "b", "decimals": 8, - "api": { - "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "bfm" }] - }, + "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info", "config": { "path": "bfm" } }], "explorer": { "url": "https://tracker.bfmeta.org", "queryTx": "https://tracker.bfmeta.org/#/info/event-details/:signature", @@ -39,9 +37,7 @@ ], "prefix": "b", "decimals": 8, - "api": { - "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "ccchain" }] - } + "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info", "config": { "path": "ccchain" } }] }, { "id": "pmchain", @@ -57,9 +53,7 @@ ], "prefix": "b", "decimals": 8, - "api": { - "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "pmchain" }] - } + "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info", "config": { "path": "pmchain" } }] }, { "id": "bfchainv2", @@ -75,9 +69,9 @@ ], "prefix": "b", "decimals": 8, - "api": { - "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "bfchainv2" }] - } + "apis": [ + { "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info", "config": { "path": "bfchainv2" } } + ] }, { "id": "btgmeta", @@ -93,9 +87,7 @@ ], "prefix": "b", "decimals": 8, - "api": { - "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "btgmeta" }] - } + "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info", "config": { "path": "btgmeta" } }] }, { "id": "biwmeta", @@ -109,9 +101,9 @@ ], "prefix": "b", "decimals": 8, - "api": { - "biowallet-v1": ["https://walletapi.biw-meta.com", { "path": "biwmeta" }] - }, + "apis": [ + { "type": "biowallet-v1", "endpoint": "https://walletapi.biw-meta.com", "config": { "path": "biwmeta" } } + ], "explorer": { "url": "https://tracker.biw-meta.info", "queryTx": "https://tracker.biw-meta.info/#/info/event-details/:signature", @@ -133,9 +125,7 @@ ], "prefix": "b", "decimals": 8, - "api": { - "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "ethmeta" }] - } + "apis": [{ "type": "biowallet-v1", "endpoint": "https://walletapi.bfmeta.info", "config": { "path": "ethmeta" } }] }, { "id": "ethereum", @@ -150,10 +140,11 @@ "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/eth" ], "decimals": 18, - "api": { - "ethereum-rpc": "https://ethereum-rpc.publicnode.com", - "blockscout-v1": "https://eth.blockscout.com/api" - }, + "apis": [ + { "type": "blockscout-v1", "endpoint": "https://eth.blockscout.com/api" }, + { "type": "ethereum-rpc", "endpoint": "https://ethereum-rpc.publicnode.com" }, + { "type": "ethwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/eth" } + ], "explorer": { "url": "https://eth.blockscout.com", "queryTx": "https://eth.blockscout.com/tx/:hash", @@ -173,10 +164,10 @@ "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bsc" ], "decimals": 18, - "api": { - "bsc-rpc": "https://bsc-rpc.publicnode.com", - "bscwallet-v1": "https://walletapi.bfmeta.info/wallet/bsc" - }, + "apis": [ + { "type": "bsc-rpc", "endpoint": "https://bsc-rpc.publicnode.com" }, + { "type": "bscwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/bsc" } + ], "explorer": { "url": "https://bscscan.com", "queryTx": "https://bscscan.com/tx/:hash", @@ -196,9 +187,15 @@ "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/tron" ], "decimals": 6, - "api": { - "tron-rpc": "https://api.trongrid.io" - }, + "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": "tronwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/tron" } + ], "explorer": { "url": "https://tronscan.org", "queryTx": "https://tronscan.org/#/transaction/:hash", @@ -218,9 +215,10 @@ "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/btcm" ], "decimals": 8, - "api": { - "mempool-v1": "https://mempool.space/api" - }, + "apis": [ + { "type": "mempool-v1", "endpoint": "https://mempool.space/api" }, + { "type": "btcwallet-v1", "endpoint": "https://walletapi.bfmeta.info/wallet/btc/blockbook" } + ], "explorer": { "url": "https://mempool.space", "queryTx": "https://mempool.space/tx/:hash", diff --git a/public/configs/testnet-chains.json b/public/configs/testnet-chains.json index 4f1490b5f..01e30b89b 100644 --- a/public/configs/testnet-chains.json +++ b/public/configs/testnet-chains.json @@ -9,9 +9,9 @@ "symbol": "SepoliaETH", "icon": "../icons/ethereum/chain.svg", "decimals": 18, - "api": { - "ethereum-rpc": "https://rpc.sepolia.org" - }, + "apis": [ + { "type": "ethereum-rpc", "endpoint": "https://rpc.sepolia.org" } + ], "explorer": { "url": "https://sepolia.etherscan.io", "queryTx": "https://sepolia.etherscan.io/tx/:hash", @@ -26,9 +26,9 @@ "symbol": "tBNB", "icon": "../icons/binance/chain.svg", "decimals": 18, - "api": { - "ethereum-rpc": "https://bsc-testnet-rpc.publicnode.com" - }, + "apis": [ + { "type": "ethereum-rpc", "endpoint": "https://bsc-testnet-rpc.publicnode.com" } + ], "explorer": { "url": "https://testnet.bscscan.com", "queryTx": "https://testnet.bscscan.com/tx/:hash", @@ -43,9 +43,9 @@ "symbol": "TRX", "icon": "../icons/tron/chain.svg", "decimals": 6, - "api": { - "tron-rpc": "https://nile.trongrid.io" - }, + "apis": [ + { "type": "tron-rpc", "endpoint": "https://nile.trongrid.io" } + ], "explorer": { "url": "https://nile.tronscan.org", "queryTx": "https://nile.tronscan.org/#/transaction/:hash", @@ -60,9 +60,9 @@ "symbol": "sBTC", "icon": "../icons/bitcoin/chain.svg", "decimals": 8, - "api": { - "mempool-v1": "https://mempool.space/signet/api" - }, + "apis": [ + { "type": "mempool-v1", "endpoint": "https://mempool.space/signet/api" } + ], "explorer": { "url": "https://mempool.space/signet", "queryTx": "https://mempool.space/signet/tx/:hash", diff --git a/scripts/set-secret.ts b/scripts/set-secret.ts index dc8b1cfe4..06f02a267 100644 --- a/scripts/set-secret.ts +++ b/scripts/set-secret.ts @@ -112,6 +112,21 @@ const SECRET_DEFINITIONS: SecretDefinition[] = [ required: true, isPassword: true, }, + + // API Keys + { + key: 'VITE_TRONGRID_API_KEY', + description: 'TronGrid API Key(用于高频率 Tron 请求)', + category: 'api-keys', + required: false, + isPassword: true, + validate: (v) => { + if (v && !/^[a-f0-9-]{36}$/i.test(v)) { + return 'API Key 格式无效(应为 UUID)' + } + return true + }, + }, ] interface CategoryDefinition { @@ -136,6 +151,11 @@ const CATEGORIES: CategoryDefinition[] = [ name: 'DWEB 开发版发布', description: 'SFTP 开发服务器账号(用于日常 CI/CD)', }, + { + id: 'api-keys', + name: 'API Keys', + description: '第三方 API 密钥(TronGrid 等)', + }, ] // ==================== 工具函数 ==================== diff --git a/src/components/wallet/wallet-address-portfolio.real.test.ts b/src/components/wallet/wallet-address-portfolio.real.test.ts new file mode 100644 index 000000000..c283a328e --- /dev/null +++ b/src/components/wallet/wallet-address-portfolio.real.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { clearProviderCache, createChainProvider } from "@/services/chain-adapter" +import type { ChainConfig } from "@/services/chain-config" + +const mockGetChainById = vi.fn<(id: string) => ChainConfig | null>() + +vi.mock("@/stores/chain-config", () => ({ + chainConfigStore: { + state: {}, + }, + chainConfigSelectors: { + getChainById: (_state: unknown, id: string) => mockGetChainById(id), + }, +})) + +async function firstSuccess(options: { + chainId: string + method: "getNativeBalance" | "getTransactionHistory" + args: unknown[] +}): Promise<{ providerType: string; result: T }> { + const provider = createChainProvider(options.chainId) + const providers = provider.getProviders() + + let lastError: unknown = null + + for (const candidate of providers) { + const fn = (candidate as any)[options.method] + if (typeof fn !== "function") continue + + try { + const result = (await fn.apply(candidate, options.args)) as T + return { providerType: candidate.type, result } + } catch (error) { + lastError = error + } + } + + throw new Error( + `All providers failed for ${options.chainId}:${options.method}: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ) +} + +describe.skipIf(!process.env.TEST_REAL_API)("WalletAddressPortfolio Real Provider Sanity", () => { + const configs: Record = { + ethereum: { + id: "ethereum", + version: "1.0", + chainKind: "evm", + name: "Ethereum", + symbol: "ETH", + decimals: 18, + enabled: true, + source: "default", + apis: [ + { type: "blockscout-v1", endpoint: "https://eth.blockscout.com/api" }, + { type: "ethereum-rpc", endpoint: "https://ethereum-rpc.publicnode.com" }, + { type: "ethwallet-v1", endpoint: "https://walletapi.bfmeta.info/wallet/eth" }, + ], + }, + tron: { + id: "tron", + version: "1.0", + chainKind: "tron", + name: "Tron", + symbol: "TRX", + decimals: 6, + enabled: true, + 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: "tronwallet-v1", endpoint: "https://walletapi.bfmeta.info/wallet/tron" }, + ], + }, + bitcoin: { + id: "bitcoin", + version: "1.0", + chainKind: "bitcoin", + name: "Bitcoin", + symbol: "BTC", + decimals: 8, + enabled: true, + source: "default", + apis: [ + { type: "mempool-v1", endpoint: "https://mempool.space/api" }, + { type: "btcwallet-v1", endpoint: "https://walletapi.bfmeta.info/wallet/btc/blockbook" }, + ], + }, + } + + beforeEach(() => { + clearProviderCache() + mockGetChainById.mockImplementation((id) => configs[id] ?? null) + }) + + it( + "ethereum should return balance>0 and txs>0", + async () => { + const address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + + const balance = await firstSuccess<{ amount: { raw: bigint } }>({ + chainId: "ethereum", + method: "getNativeBalance", + args: [address], + }) + expect(balance.result.amount.raw).toBeGreaterThan(0n) + console.log(`[real] ethereum balance provider: ${balance.providerType}`) + + const history = await firstSuccess({ + chainId: "ethereum", + method: "getTransactionHistory", + args: [address, 10], + }) + expect(history.result.length).toBeGreaterThan(0) + console.log(`[real] ethereum txHistory provider: ${history.providerType}`) + }, + 30_000, + ) + + it( + "tron should return balance>0 and txs>0", + async () => { + const address = "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9" + + const balance = await firstSuccess<{ amount: { raw: bigint } }>({ + chainId: "tron", + method: "getNativeBalance", + args: [address], + }) + expect(balance.result.amount.raw).toBeGreaterThan(0n) + console.log(`[real] tron balance provider: ${balance.providerType}`) + + const history = await firstSuccess({ + chainId: "tron", + method: "getTransactionHistory", + args: [address, 10], + }) + expect(history.result.length).toBeGreaterThan(0) + console.log(`[real] tron txHistory provider: ${history.providerType}`) + }, + 30_000, + ) + + it( + "bitcoin should return balance>0 and txs>0", + async () => { + const address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + + const balance = await firstSuccess<{ amount: { raw: bigint } }>({ + chainId: "bitcoin", + method: "getNativeBalance", + args: [address], + }) + expect(balance.result.amount.raw).toBeGreaterThan(0n) + console.log(`[real] bitcoin balance provider: ${balance.providerType}`) + + const history = await firstSuccess({ + chainId: "bitcoin", + method: "getTransactionHistory", + args: [address, 10], + }) + expect(history.result.length).toBeGreaterThan(0) + console.log(`[real] bitcoin txHistory provider: ${history.providerType}`) + }, + 30_000, + ) +}) diff --git a/src/hooks/use-security-password.ts b/src/hooks/use-security-password.ts index 758716a21..708e4e82d 100644 --- a/src/hooks/use-security-password.ts +++ b/src/hooks/use-security-password.ts @@ -95,8 +95,9 @@ export function useSecurityPassword({ const refresh = useCallback(async () => { if (!chainConfig || !address) return - const apiUrl = chainConfig.api?.url - const apiPath = chainConfig.api?.path ?? chainConfig.id + const biowallet = chainConfig.apis.find((p) => p.type === 'biowallet-v1') + const apiUrl = biowallet?.endpoint + const apiPath = (biowallet?.config?.path as string | undefined) ?? chainConfig.id if (!apiUrl) { securityPasswordActions.setError(address, 'API URL 未配置') return diff --git a/src/hooks/use-send.bioforest.ts b/src/hooks/use-send.bioforest.ts index 9913923a4..de9aabff1 100644 --- a/src/hooks/use-send.bioforest.ts +++ b/src/hooks/use-send.bioforest.ts @@ -17,6 +17,13 @@ export interface BioforestFeeResult { symbol: string } +function getBioforestApi(chainConfig: ChainConfig): { apiUrl: string | null; apiPath: string } { + const biowallet = chainConfig.apis.find((p) => p.type === 'biowallet-v1') + const apiUrl = biowallet?.endpoint ?? null + const apiPath = (biowallet?.config?.path as string | undefined) ?? chainConfig.id + return { apiUrl, apiPath } +} + export async function fetchBioforestFee(chainConfig: ChainConfig, fromAddress: string): Promise { const adapter = createBioforestAdapter(chainConfig) const feeEstimate = await adapter.transaction.estimateFee({ @@ -67,8 +74,7 @@ export async function checkTwoStepSecretRequired( chainConfig: ChainConfig, address: string, ): Promise<{ required: boolean; secondPublicKey?: string }> { - const apiUrl = chainConfig.api?.url - const apiPath = chainConfig.api?.path ?? chainConfig.id + const { apiUrl, apiPath } = getBioforestApi(chainConfig) if (!apiUrl) { return { required: false } } @@ -131,8 +137,7 @@ export async function submitBioforestTransfer({ return { status: 'error', message: '请输入有效金额' } } - const apiUrl = chainConfig.api?.url - const apiPath = chainConfig.api?.path ?? chainConfig.id + const { apiUrl, apiPath } = getBioforestApi(chainConfig) if (!apiUrl) { return { status: 'error', message: 'API URL 未配置' } } @@ -245,8 +250,7 @@ export async function submitSetTwoStepSecret({ } } - const apiUrl = chainConfig.api?.url - const apiPath = chainConfig.api?.path ?? chainConfig.id + const { apiUrl, apiPath } = getBioforestApi(chainConfig) if (!apiUrl) { return { status: 'error', message: 'API URL 未配置' } } @@ -292,8 +296,7 @@ export async function submitSetTwoStepSecret({ export async function getSetTwoStepSecretFee( chainConfig: ChainConfig, ): Promise<{ amount: Amount; symbol: string } | null> { - const apiUrl = chainConfig.api?.url - const apiPath = chainConfig.api?.path ?? chainConfig.id + const { apiUrl, apiPath } = getBioforestApi(chainConfig) if (!apiUrl) { return null } @@ -317,8 +320,7 @@ export async function hasTwoStepSecretSet( chainConfig: ChainConfig, address: string, ): Promise { - const apiUrl = chainConfig.api?.url - const apiPath = chainConfig.api?.path ?? chainConfig.id + const { apiUrl, apiPath } = getBioforestApi(chainConfig) if (!apiUrl) { return false } diff --git a/src/lib/crypto/bioforest.test.ts b/src/lib/crypto/bioforest.test.ts index f9905e277..33df1294a 100644 --- a/src/lib/crypto/bioforest.test.ts +++ b/src/lib/crypto/bioforest.test.ts @@ -311,7 +311,7 @@ describe('BioForest Crypto', () => { name: 'Ethereum', symbol: 'ETH', decimals: 18, - api: { url: 'https://example.invalid', path: 'eth' }, + apis: [{ type: 'ethereum-rpc', endpoint: 'https://example.invalid' }], enabled: true, source: 'default', } diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 96b6ace91..5e5987e42 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -149,8 +149,9 @@ export function SettingsPage() { // checkConfirmed callback - 检查交易是否上链 async () => { const { getAddressInfo } = await import('@/services/bioforest-sdk'); - const apiUrl = chainConfig.api?.url; - const apiPath = chainConfig.api?.path ?? chainConfig.id; + const biowallet = chainConfig.apis.find((p) => p.type === 'biowallet-v1'); + const apiUrl = biowallet?.endpoint; + const apiPath = (biowallet?.config?.path as string | undefined) ?? chainConfig.id; if (!apiUrl) return false; try { const info = await getAddressInfo(apiUrl, apiPath, bfmAddress.address); diff --git a/src/queries/use-security-password-query.ts b/src/queries/use-security-password-query.ts index e1401cdc1..6b2085242 100644 --- a/src/queries/use-security-password-query.ts +++ b/src/queries/use-security-password-query.ts @@ -49,8 +49,9 @@ export function useSecurityPasswordQuery( return { address, secondPublicKey: null } } - const apiUrl = chainConfig.api?.url - const apiPath = chainConfig.api?.path ?? chainConfig.id + const biowallet = chainConfig.apis.find((p) => p.type === 'biowallet-v1') + const apiUrl = biowallet?.endpoint + const apiPath = (biowallet?.config?.path as string | undefined) ?? chainConfig.id if (!apiUrl) { console.warn(`[useSecurityPasswordQuery] API URL not configured for ${chain}`) diff --git a/src/services/chain-adapter/__tests__/bioforest-api.test.ts b/src/services/chain-adapter/__tests__/bioforest-api.test.ts index da658e56b..cf390d2c9 100644 --- a/src/services/chain-adapter/__tests__/bioforest-api.test.ts +++ b/src/services/chain-adapter/__tests__/bioforest-api.test.ts @@ -19,7 +19,7 @@ const mockConfigWithRpc: ChainConfig = { decimals: 8, enabled: true, source: 'default', - api: { url: 'https://walletapi.bfmeta.info', path: 'bfm' }, + apis: [{ type: 'biowallet-v1', endpoint: 'https://walletapi.bfmeta.info', config: { path: 'bfm' } }], } // Mock chainConfigService diff --git a/src/services/chain-adapter/bioforest/asset-service.test.ts b/src/services/chain-adapter/bioforest/asset-service.test.ts index da37a2354..94de876e1 100644 --- a/src/services/chain-adapter/bioforest/asset-service.test.ts +++ b/src/services/chain-adapter/bioforest/asset-service.test.ts @@ -13,14 +13,12 @@ vi.mock('@/services/chain-config', () => ({ if (chainId === 'bfmeta') { return { id: 'bfmeta', + version: '1.0', chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFM', decimals: 8, - api: { - url: 'https://walletapi.bfmeta.info', - path: 'bfm', - }, + apis: [{ type: 'biowallet-v1', endpoint: 'https://walletapi.bfmeta.info', config: { path: 'bfm' } }], } } return null diff --git a/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts b/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts index e6f251be8..2c4e684cf 100644 --- a/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts +++ b/src/services/chain-adapter/providers/__tests__/biowallet-provider.biwmeta.real.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,8 +13,13 @@ vi.mock('@/services/chain-config', () => ({ })); const mockFetch = vi.fn(); +const originalFetch = global.fetch; global.fetch = mockFetch; +afterAll(() => { + global.fetch = originalFetch; +}); + function readFixture(name: string): T { const dir = path.dirname(fileURLToPath(import.meta.url)); const filePath = path.join(dir, 'fixtures/real', name); diff --git a/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts b/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts index 875ef2787..a080c5c16 100644 --- a/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts +++ b/src/services/chain-adapter/providers/__tests__/biowallet-provider.real.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -13,8 +13,13 @@ vi.mock('@/services/chain-config', () => ({ })) const mockFetch = vi.fn() +const originalFetch = global.fetch global.fetch = mockFetch +afterAll(() => { + global.fetch = originalFetch +}) + function readFixture(name: string): T { const dir = path.dirname(fileURLToPath(import.meta.url)) const filePath = path.join(dir, 'fixtures/real', name) diff --git a/src/services/chain-adapter/providers/__tests__/btcwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/btcwallet-provider.test.ts new file mode 100644 index 000000000..cbe9aef0c --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/btcwallet-provider.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' +import { BtcWalletProvider, createBtcwalletProvider } from '../btcwallet-provider' +import type { ParsedApiEntry } from '@/services/chain-config' + +vi.mock('@/services/chain-config', () => ({ + chainConfigService: { + getSymbol: () => 'BTC', + getDecimals: () => 8, + }, +})) + +const mockFetch = vi.fn() +const originalFetch = global.fetch +global.fetch = mockFetch + +afterAll(() => { + global.fetch = originalFetch +}) + +describe('BtcWalletProvider', () => { + const entry: ParsedApiEntry = { + type: 'btcwallet-v1', + endpoint: 'https://walletapi.example.com/wallet/btc/blockbook', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createBtcwalletProvider creates provider for btcwallet-v1', () => { + const provider = createBtcwalletProvider(entry, 'bitcoin') + expect(provider).toBeInstanceOf(BtcWalletProvider) + }) + + it('maps balance (confirmed + unconfirmed) to Amount', async () => { + const address = 'bc1qexample' + + mockFetch.mockImplementation(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + expect(body.method).toBe('GET') + expect(body.url).toContain(`/api/v2/address/${address}`) + return { ok: true, json: async () => ({ balance: '10', unconfirmedBalance: '-2' }) } + }) + + const provider = new BtcWalletProvider(entry, 'bitcoin') + const balance = await provider.getNativeBalance(address) + expect(balance.amount.raw).toBe(8n) + }) + + it('computes direction and value from vin/vout', async () => { + const address = 'bc1qme' + + mockFetch.mockImplementation(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) + if (String(body.url).includes('details=txs')) { + return { + ok: true, + json: async () => ({ + balance: '0', + transactions: [ + { + txid: 'tx1', + blockHeight: 100, + confirmations: 1, + blockTime: 1700000000, + vin: [{ addresses: [address], value: '5000' }], + vout: [{ addresses: ['bc1qother'], value: '3000' }, { addresses: [address], value: '1900' }], + }, + ], + }), + } + } + return { ok: true, json: async () => ({ balance: '0' }) } + }) + + const provider = new BtcWalletProvider(entry, 'bitcoin') + const txs = await provider.getTransactionHistory(address, 10) + + expect(txs).toHaveLength(1) + expect(txs[0].direction).toBe('out') + expect(txs[0].assets[0]).toMatchObject({ + assetType: 'native', + symbol: 'BTC', + decimals: 8, + value: '3100', + }) + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts index da46ca910..78a280afc 100644 --- a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts @@ -3,6 +3,13 @@ import { ChainProvider } from '../chain-provider' import type { ApiProvider, Balance, Transaction } from '../types' import { Amount } from '@/types/amount' +vi.mock('@/services/chain-config', () => ({ + chainConfigService: { + getSymbol: () => 'TEST', + getDecimals: () => 8, + }, +})) + // Mock ApiProvider function createMockProvider(overrides: Partial = {}): ApiProvider { return { @@ -13,6 +20,10 @@ function createMockProvider(overrides: Partial = {}): ApiProvider { } describe('ChainProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + describe('supports', () => { it('returns true when a provider implements the method', () => { const provider = createMockProvider({ @@ -66,10 +77,16 @@ describe('ChainProvider', () => { hash: '0xabc', from: '0x1', to: '0x2', - value: '1000', - symbol: 'TEST', timestamp: Date.now(), status: 'confirmed', + action: 'transfer', + direction: 'out', + assets: [{ + assetType: 'native', + value: '1000', + symbol: 'TEST', + decimals: 8, + }], }] const provider = createMockProvider({ getTransactionHistory: vi.fn().mockResolvedValue(mockTxs), @@ -84,6 +101,58 @@ describe('ChainProvider', () => { expect(provider.getTransactionHistory).toHaveBeenCalledWith('0x123', 10) }) + it('falls back to the next provider when the first throws', async () => { + const p1 = createMockProvider({ + type: 'p1', + getNativeBalance: vi.fn().mockRejectedValue(new Error('p1 down')), + }) + const mockBalance: Balance = { + amount: Amount.fromRaw('123', 8, 'TEST'), + symbol: 'TEST', + } + const p2 = createMockProvider({ + type: 'p2', + getNativeBalance: vi.fn().mockResolvedValue(mockBalance), + }) + + const chainProvider = new ChainProvider('test', [p1, p2]) + const result = await chainProvider.getNativeBalance!('0x123') + + expect(result).toEqual(mockBalance) + expect(p1.getNativeBalance).toHaveBeenCalledTimes(1) + expect(p2.getNativeBalance).toHaveBeenCalledTimes(1) + }) + + it('returns safe defaults for read methods when all providers fail', async () => { + const p1 = createMockProvider({ + type: 'p1', + getTransactionHistory: vi.fn().mockRejectedValue(new Error('nope')), + }) + const p2 = createMockProvider({ + type: 'p2', + getTransactionHistory: vi.fn().mockRejectedValue(new Error('still nope')), + }) + + const chainProvider = new ChainProvider('test', [p1, p2]) + const txs = await chainProvider.getTransactionHistory!('0xabc', 10) + + expect(txs).toEqual([]) + }) + + it('rethrows for non-read methods when all providers fail', async () => { + const p1 = createMockProvider({ + type: 'p1', + estimateFee: vi.fn().mockRejectedValue(new Error('fee fail')), + }) + const chainProvider = new ChainProvider('test', [p1]) + + await expect(chainProvider.estimateFee!({ + from: 'a', + to: 'b', + amount: Amount.fromRaw('1', 8, 'TEST'), + })).rejects.toThrow('fee fail') + }) + it('returns undefined for unimplemented methods', () => { const provider = createMockProvider() const chainProvider = new ChainProvider('test', [provider]) diff --git a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts index e771cfd49..c4c898b4c 100644 --- a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts +++ b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { EtherscanProvider, createEtherscanProvider } from '../etherscan-provider' +import { resetFetchJsonForTests } from '../fetch-json' import type { ParsedApiEntry } from '@/services/chain-config' // Mock chainConfigService @@ -15,8 +16,13 @@ vi.mock('@/services/chain-config', () => ({ // Mock fetch const mockFetch = vi.fn() +const originalFetch = global.fetch global.fetch = mockFetch +afterAll(() => { + global.fetch = originalFetch +}) + function readFixture(name: string): T { const dir = path.dirname(fileURLToPath(import.meta.url)) const filePath = path.join(dir, 'fixtures/real', name) @@ -31,6 +37,7 @@ describe('EtherscanProvider', () => { beforeEach(() => { vi.clearAllMocks() + resetFetchJsonForTests() }) describe('createEtherscanProvider', () => { diff --git a/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts new file mode 100644 index 000000000..34e365f77 --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/ethwallet-provider.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' +import { EthWalletProvider, createEthwalletProvider } from '../ethwallet-provider' +import type { ParsedApiEntry } from '@/services/chain-config' + +vi.mock('@/services/chain-config', () => ({ + chainConfigService: { + getSymbol: () => 'ETH', + getDecimals: () => 18, + }, +})) + +const mockFetch = vi.fn() +const originalFetch = global.fetch +global.fetch = mockFetch + +afterAll(() => { + global.fetch = originalFetch +}) + +describe('EthWalletProvider', () => { + const entry: ParsedApiEntry = { + type: 'ethwallet-v1', + endpoint: 'https://walletapi.example.com/wallet/eth', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createEthwalletProvider creates provider for ethwallet-v1', () => { + const provider = createEthwalletProvider(entry, 'ethereum') + expect(provider).toBeInstanceOf(EthWalletProvider) + }) + + it('maps balance string to Amount', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => '1000000000000000000' }) + + const provider = new EthWalletProvider(entry, 'ethereum') + const balance = await provider.getNativeBalance('0xabc') + + expect(balance.symbol).toBe('ETH') + expect(balance.amount.raw).toBe(1000000000000000000n) + }) + + it('aggregates normal + erc20 history by hash and prioritizes token asset', async () => { + const user = '0x75a6F48BF634868b2980c97CcEf467A127597e08' + const hash = '0xabc123' + + const nativeTx = { + hash, + from: user, + to: '0xContract', + value: '0', + timeStamp: '1700000000', + isError: '0', + blockNumber: '123', + methodId: '0xa9059cbb', + functionName: 'transfer(address,uint256)', + } + const tokenTx = { + hash, + from: user, + to: '0xReceiver', + value: '3000000', + timeStamp: '1700000000', + blockNumber: '123', + tokenSymbol: 'USDT', + tokenName: 'Tether USD', + tokenDecimal: '6', + contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + } + + 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] }) } + } + 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 () => ({ status: '1', result: [] }) } + }) + + const provider = new EthWalletProvider(entry, 'ethereum') + const txs = await provider.getTransactionHistory(user, 10) + + expect(txs).toHaveLength(1) + expect(txs[0].hash).toBe(hash) + expect(txs[0].assets[0]).toMatchObject({ + assetType: 'token', + symbol: 'USDT', + decimals: 6, + contractAddress: tokenTx.contractAddress, + value: tokenTx.value, + }) + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/integration.test.ts b/src/services/chain-adapter/providers/__tests__/integration.test.ts index 8dfaa8c90..dfbffc770 100644 --- a/src/services/chain-adapter/providers/__tests__/integration.test.ts +++ b/src/services/chain-adapter/providers/__tests__/integration.test.ts @@ -35,10 +35,10 @@ describe('ChainProvider 集成测试', () => { decimals: 18, enabled: true, source: 'default', - api: { - 'ethereum-rpc': 'https://ethereum-rpc.publicnode.com', - 'etherscan-v2': 'https://api.etherscan.io/v2/api', - }, + apis: [ + { type: 'etherscan-v2', endpoint: 'https://api.etherscan.io/v2/api' }, + { type: 'ethereum-rpc', endpoint: 'https://ethereum-rpc.publicnode.com' }, + ], } mockGetChainById.mockReturnValue(mockEthConfig) @@ -72,9 +72,7 @@ describe('ChainProvider 集成测试', () => { prefix: 'b', enabled: true, source: 'default', - api: { - 'biowallet-v1': ['https://walletapi.bfmeta.info', { path: 'bfmeta' }], - }, + apis: [{ type: 'biowallet-v1', endpoint: 'https://walletapi.bfmeta.info', config: { path: 'bfmeta' } }], } mockGetChainById.mockReturnValue(mockBfmetaConfig) @@ -112,9 +110,7 @@ describe('ChainProvider 集成测试', () => { decimals: 18, enabled: true, source: 'default', - api: { - 'ethereum-rpc': 'https://rpc.test.com', - }, + apis: [{ type: 'ethereum-rpc', endpoint: 'https://rpc.test.com' }], } mockGetChainById.mockReturnValue(mockConfig) 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 368c67f1f..166bc9d58 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 @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' import { TronRpcProvider, createTronRpcProvider } from '../tron-rpc-provider' +import { resetFetchJsonForTests } from '../fetch-json' import type { ParsedApiEntry } from '@/services/chain-config' vi.mock('@/services/chain-config', () => ({ @@ -10,8 +11,13 @@ vi.mock('@/services/chain-config', () => ({ })) const mockFetch = vi.fn() +const originalFetch = global.fetch global.fetch = mockFetch +afterAll(() => { + global.fetch = originalFetch +}) + describe('TronRpcProvider', () => { const mockEntry: ParsedApiEntry = { type: 'tron-rpc', @@ -20,6 +26,7 @@ describe('TronRpcProvider', () => { beforeEach(() => { vi.clearAllMocks() + resetFetchJsonForTests() }) describe('createTronRpcProvider', () => { @@ -28,15 +35,24 @@ describe('TronRpcProvider', () => { expect(provider).toBeInstanceOf(TronRpcProvider) }) - it('creates provider for tron-* type', () => { + it('creates provider for tron-rpc-pro type', () => { const entry: ParsedApiEntry = { - type: 'tron-grid', + type: 'tron-rpc-pro', endpoint: 'https://api.trongrid.io', } const provider = createTronRpcProvider(entry, 'tron') expect(provider).toBeInstanceOf(TronRpcProvider) }) + it('returns null for tronwallet-v1', () => { + const entry: ParsedApiEntry = { + type: 'tronwallet-v1', + endpoint: 'https://walletapi.example.com/wallet/tron', + } + const provider = createTronRpcProvider(entry, 'tron') + expect(provider).toBeNull() + }) + it('returns null for non-tron type', () => { const rpcEntry: ParsedApiEntry = { type: 'ethereum-rpc', @@ -47,6 +63,41 @@ 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' + + try { + const entry: ParsedApiEntry = { + type: 'tron-rpc-pro', + endpoint: 'https://api.trongrid.io', + config: { apiKeyEnv: 'VITE_TRONGRID_API_KEY' }, + } + + mockFetch.mockImplementationOnce(async (_url: string, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record + expect(headers['TRON-PRO-API-KEY']).toBe('test-trongrid-key') + return { + ok: true, + json: async () => ({ block_header: { raw_data: { number: 1 } } }), + } as any + }) + + const provider = new TronRpcProvider(entry, 'tron') + const height = await provider.getBlockHeight() + expect(height).toBe(1n) + } finally { + if (previous === undefined) { + delete process.env.VITE_TRONGRID_API_KEY + } else { + process.env.VITE_TRONGRID_API_KEY = previous + } + } + }) + }) + + describe('getTransactionHistory', () => { it('aggregates native and TRC-20 transactions by txID', async () => { const userAddress = 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' diff --git a/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts b/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts new file mode 100644 index 000000000..f0cb43e1d --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/tronwallet-provider.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' +import { TronWalletProvider, createTronwalletProvider } from '../tronwallet-provider' +import type { ParsedApiEntry } from '@/services/chain-config' + +vi.mock('@/services/chain-config', () => ({ + chainConfigService: { + getSymbol: () => 'TRX', + getDecimals: () => 6, + }, +})) + +const mockFetch = vi.fn() +const originalFetch = global.fetch +global.fetch = mockFetch + +afterAll(() => { + global.fetch = originalFetch +}) + +/** Base58 alphabet used by Tron */ +const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +function decodeBase58(str: string): Uint8Array { + let num = BigInt(0) + for (const char of str) { + const index = ALPHABET.indexOf(char) + if (index === -1) throw new Error('Invalid base58') + num = num * 58n + BigInt(index) + } + + const hex = num.toString(16).padStart(50, '0') + const bytes = new Uint8Array(25) + for (let i = 0; i < 25; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +function tronBase58ToHex(address: string): string { + const payload = decodeBase58(address).slice(0, 21) + return Array.from(payload).map((b) => b.toString(16).padStart(2, '0')).join('') +} + +describe('TronWalletProvider', () => { + const entry: ParsedApiEntry = { + type: 'tronwallet-v1', + endpoint: 'https://walletapi.example.com/wallet/tron', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createTronwalletProvider creates provider for tronwallet-v1', () => { + const provider = createTronwalletProvider(entry, 'tron') + expect(provider).toBeInstanceOf(TronWalletProvider) + }) + + it('converts base58 address to hex for balance query', async () => { + const address = 'TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g' + const expectedHex = tronBase58ToHex(address) + + mockFetch.mockImplementation(async (url: string) => { + expect(url).toContain(`/balance?address=${expectedHex}`) + return { ok: true, json: async () => '1000000' } + }) + + const provider = new TronWalletProvider(entry, 'tron') + const balance = await provider.getNativeBalance(address) + expect(balance.amount.raw).toBe(1000000n) + }) + + it('maps tx history and converts hex addresses to base58', async () => { + const address = 'TZ4UXDV5ZhNW7fb2AMSbgfAEZ7hWsnYS2g' + const hexAddress = tronBase58ToHex(address) + + const nativeTx = { + contractRet: 'SUCCESS', + txID: 'tx1', + blockNumber: 10, + from: hexAddress, + to: hexAddress, + amount: 123, + timestamp: 1700000000000, + } + + mockFetch.mockImplementation(async (url: string) => { + if (url.endsWith('/trans/common/history')) { + return { ok: true, json: async () => ({ success: true, data: [nativeTx] }) } + } + if (url.endsWith('/trans/trc20/history')) { + return { ok: true, json: async () => ({ success: true, data: [] }) } + } + return { ok: true, json: async () => ({ success: true, data: [] }) } + }) + + const provider = new TronWalletProvider(entry, 'tron') + const txs = await provider.getTransactionHistory(address, 10) + expect(txs).toHaveLength(1) + expect(txs[0].from).toBe(address) + expect(txs[0].to).toBe(address) + expect(txs[0].assets[0]).toMatchObject({ + assetType: 'native', + symbol: 'TRX', + decimals: 6, + value: '123', + }) + }) +}) diff --git a/src/services/chain-adapter/providers/biowallet-provider.ts b/src/services/chain-adapter/providers/biowallet-provider.ts index cfd971700..0e7b76fba 100644 --- a/src/services/chain-adapter/providers/biowallet-provider.ts +++ b/src/services/chain-adapter/providers/biowallet-provider.ts @@ -9,6 +9,7 @@ import type { ApiProvider, Balance, Transaction, TokenBalance, Action, Direction import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' +import { fetchJson, observeValueAndInvalidate } from './fetch-json' const BiowalletAssetItemSchema = z.looseObject({ assetNumber: z.string(), @@ -108,104 +109,112 @@ export class BiowalletProvider implements ApiProvider { } async getNativeBalance(address: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/address/asset`, { + const url = `${this.baseUrl}/address/asset` + const json: unknown = await fetchJson( + url, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address }), - }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } + }, + { + cacheKey: `biowallet:${this.path}:assets:${address}`, + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }, + ) + + const parsed = BiowalletAssetSchema.safeParse(json) + if (!parsed.success || !parsed.data.success || !parsed.data.result) { + throw new Error('Upstream API error') + } - const json: unknown = await response.json() - const parsed = BiowalletAssetSchema.safeParse(json) - - if (!parsed.success || !parsed.data.success || !parsed.data.result) { - return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } - } + // 查找原生代币余额 + for (const magic of Object.values(parsed.data.result.assets)) { + for (const asset of Object.values(magic)) { + if (asset.assetType === this.symbol) { + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: asset.assetNumber, + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) - // 查找原生代币余额 - for (const magic of Object.values(parsed.data.result.assets)) { - for (const asset of Object.values(magic)) { - if (asset.assetType === this.symbol) { - return { - amount: Amount.fromRaw(asset.assetNumber, this.decimals, this.symbol), - symbol: this.symbol, - } + return { + amount: Amount.fromRaw(asset.assetNumber, this.decimals, this.symbol), + symbol: this.symbol, } } } - - return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } - } catch (error) { - console.warn('[BiowalletProvider] Error fetching balance:', error) - return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } } + + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: '0', + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) + + return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } } async getTokenBalances(address: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/address/asset`, { + const url = `${this.baseUrl}/address/asset` + const json: unknown = await fetchJson( + url, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address }), - }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - - const json: unknown = await response.json() - const parsed = BiowalletAssetSchema.safeParse(json) - - if (!parsed.success || !parsed.data.success || !parsed.data.result) { - return [] - } + }, + { + cacheKey: `biowallet:${this.path}:assets:${address}`, + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }, + ) + + const parsed = BiowalletAssetSchema.safeParse(json) + if (!parsed.success || !parsed.data.success || !parsed.data.result) { + throw new Error('Upstream API error') + } - const tokens: TokenBalance[] = [] + const tokens: TokenBalance[] = [] - for (const magic of Object.values(parsed.data.result.assets)) { - for (const asset of Object.values(magic)) { - const isNative = asset.assetType === this.symbol - tokens.push({ - symbol: asset.assetType, - name: asset.assetType, - amount: Amount.fromRaw(asset.assetNumber, this.decimals, asset.assetType), - isNative, - }) - } + for (const magic of Object.values(parsed.data.result.assets)) { + for (const asset of Object.values(magic)) { + const isNative = asset.assetType === this.symbol + tokens.push({ + symbol: asset.assetType, + name: asset.assetType, + amount: Amount.fromRaw(asset.assetNumber, this.decimals, asset.assetType), + isNative, + }) } + } - // Sort: native first, then by amount descending - tokens.sort((a, b) => { - if (a.isNative && !b.isNative) return -1 - if (!a.isNative && b.isNative) return 1 - return b.amount.toNumber() - a.amount.toNumber() - }) + tokens.sort((a, b) => { + if (a.isNative && !b.isNative) return -1 + if (!a.isNative && b.isNative) return 1 + return b.amount.toNumber() - a.amount.toNumber() + }) - return tokens - } catch (error) { - console.warn('[BiowalletProvider] Error fetching token balances:', error) - return [] - } + return tokens } async getTransactionHistory(address: string, limit = 20): Promise { - try { - // 先获取最新区块高度 - const blockResponse = await fetch(`${this.baseUrl}/lastblock`) - if (!blockResponse.ok) return [] - - const blockJson: unknown = await blockResponse.json() - const blockParsed = BiowalletBlockSchema.safeParse(blockJson) - if (!blockParsed.success || !blockParsed.data.success || !blockParsed.data.result) return [] - - const maxHeight = blockParsed.data.result.height - - // 查询交易 - const response = await fetch(`${this.baseUrl}/transactions/query`, { + const blockJson: unknown = await fetchJson(`${this.baseUrl}/lastblock`, undefined, { + cacheKey: `biowallet:${this.path}:lastblock`, + ttlMs: 10_000, + }) + const blockParsed = BiowalletBlockSchema.safeParse(blockJson) + if (!blockParsed.success || !blockParsed.data.success || !blockParsed.data.result) { + throw new Error('Upstream API error') + } + + const maxHeight = blockParsed.data.result.height + + const json: unknown = await fetchJson( + `${this.baseUrl}/transactions/query`, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -213,49 +222,48 @@ export class BiowalletProvider implements ApiProvider { address, limit, }), - }) - - if (!response.ok) return [] - - const json: unknown = await response.json() - const parsed = BiowalletTxResponseSchema.safeParse(json) - - if (!parsed.success || !parsed.data.success || !parsed.data.result?.trs) return [] - - const normalizedAddress = address.toLowerCase() - - return parsed.data.result.trs - .map((item): Transaction | null => { - const tx = item.transaction - const action = this.detectAction(tx.type) - const direction = this.getDirection(tx.senderId, tx.recipientId, normalizedAddress) - - // 获取金额和资产类型 - const { value, assetType } = this.extractAssetInfo(tx) - if (value === null) return null - - return { - hash: item.signature, - from: tx.senderId, - to: tx.recipientId, - timestamp: tx.timestamp * 1000, - status: 'confirmed', - blockNumber: BigInt(item.height), - action, - direction, - assets: [{ - assetType: 'native' as const, - value, - symbol: assetType, - decimals: this.decimals, - }], - } - }) - .filter((tx): tx is Transaction => tx !== null) - } catch (error) { - console.warn('[BiowalletProvider] Error fetching transactions:', error) - return [] + }, + { + cacheKey: `biowallet:${this.path}:txs:${address}:${limit}:${maxHeight}`, + ttlMs: 5 * 60_000, + tags: [`txhistory:${this.chainId}:${address}`], + }, + ) + + const parsed = BiowalletTxResponseSchema.safeParse(json) + if (!parsed.success || !parsed.data.success || !parsed.data.result?.trs) { + throw new Error('Upstream API error') } + + const normalizedAddress = address.toLowerCase() + + return parsed.data.result.trs + .map((item): Transaction | null => { + const tx = item.transaction + const action = this.detectAction(tx.type) + const direction = this.getDirection(tx.senderId, tx.recipientId, normalizedAddress) + + const { value, assetType } = this.extractAssetInfo(tx) + if (value === null) return null + + return { + hash: item.signature, + from: tx.senderId, + to: tx.recipientId, + timestamp: tx.timestamp * 1000, + status: 'confirmed', + blockNumber: BigInt(item.height), + action, + direction, + assets: [{ + assetType: 'native' as const, + value, + symbol: assetType, + decimals: this.decimals, + }], + } + }) + .filter((tx): tx is Transaction => tx !== null) } private detectAction(txType: string): Action { @@ -404,18 +412,15 @@ export class BiowalletProvider implements ApiProvider { } async getBlockHeight(): Promise { - try { - const response = await fetch(`${this.baseUrl}/lastblock`) - if (!response.ok) return 0n - - const json: unknown = await response.json() - const parsed = BiowalletBlockSchema.safeParse(json) - if (!parsed.success || !parsed.data.success || !parsed.data.result) return 0n - - return BigInt(parsed.data.result.height) - } catch { - return 0n + const json: unknown = await fetchJson(`${this.baseUrl}/lastblock`, undefined, { + cacheKey: `biowallet:${this.path}:lastblock`, + ttlMs: 10_000, + }) + const parsed = BiowalletBlockSchema.safeParse(json) + if (!parsed.success || !parsed.data.success || !parsed.data.result) { + throw new Error('Upstream API error') } + return BigInt(parsed.data.result.height) } } diff --git a/src/services/chain-adapter/providers/bscwallet-provider.ts b/src/services/chain-adapter/providers/bscwallet-provider.ts index 6ce04e4fc..b7b7dc425 100644 --- a/src/services/chain-adapter/providers/bscwallet-provider.ts +++ b/src/services/chain-adapter/providers/bscwallet-provider.ts @@ -10,6 +10,7 @@ import type { ApiProvider, Balance, Transaction, Direction } from './types' import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' +import { fetchJson, observeValueAndInvalidate } from './fetch-json' const BalanceResponseSchema = z.looseObject({ success: z.boolean(), @@ -36,13 +37,15 @@ const TxHistoryResponseSchema = z.looseObject({ export class BscWalletProvider implements ApiProvider { readonly type: string - readonly baseUrl: string + readonly endpoint: string readonly decimals: number readonly symbol: string + private readonly chainId: string - constructor(entry: ParsedApiEntry, chainDecimals: number, chainSymbol: string) { - this.type = entry.key - this.baseUrl = entry.url.replace(/\/$/, '') + constructor(entry: ParsedApiEntry, chainId: string, chainDecimals: number, chainSymbol: string) { + this.type = entry.type + this.endpoint = entry.endpoint.replace(/\/$/, '') + this.chainId = chainId this.decimals = chainDecimals this.symbol = chainSymbol } @@ -56,32 +59,35 @@ export class BscWalletProvider implements ApiProvider { } async getNativeBalance(address: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/balance?address=${address}`) - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - - const json: unknown = await response.json() - const parsed = BalanceResponseSchema.safeParse(json) + const url = `${this.endpoint}/balance?address=${address}` + const json: unknown = await fetchJson(url, undefined, { + cacheKey: `bscwallet:balance:${address}`, + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }) + + const parsed = BalanceResponseSchema.safeParse(json) + if (!parsed.success || !parsed.data.success) { + throw new Error('Upstream API error') + } - if (!parsed.success || !parsed.data.success) { - return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } - } + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: parsed.data.result, + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) - return { - amount: Amount.fromRaw(parsed.data.result, this.decimals, this.symbol), - symbol: this.symbol, - } - } catch (error) { - console.warn('[BscWalletProvider] Error fetching balance:', error) - return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } + return { + amount: Amount.fromRaw(parsed.data.result, this.decimals, this.symbol), + symbol: this.symbol, } } async getTransactionHistory(address: string, limit = 20): Promise { - try { - const response = await fetch(`${this.baseUrl}/trans/normal/history`, { + const url = `${this.endpoint}/trans/normal/history` + const json: unknown = await fetchJson( + url, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -89,47 +95,46 @@ export class BscWalletProvider implements ApiProvider { page: 1, offset: limit, }), - }) + }, + { + cacheKey: `bscwallet:txs:${address}:${limit}`, + ttlMs: 5 * 60_000, + tags: [`txhistory:${this.chainId}:${address}`], + }, + ) + + const parsed = TxHistoryResponseSchema.safeParse(json) + if (!parsed.success || !parsed.data.success) { + throw new Error('Upstream API error') + } + if (parsed.data.result.status !== '1') { + return [] + } - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } + const normalizedAddress = address.toLowerCase() - const json: unknown = await response.json() - const parsed = TxHistoryResponseSchema.safeParse(json) + return parsed.data.result.result.map((tx): Transaction => { + const from = tx.from + const to = tx.to + const direction = this.getDirection(from, to, normalizedAddress) - if (!parsed.success || !parsed.data.success || parsed.data.result.status !== '1') { - return [] + return { + hash: tx.hash, + from, + to, + timestamp: Number(tx.timeStamp) * 1000, + status: tx.isError === '1' ? 'failed' : 'confirmed', + blockNumber: BigInt(tx.blockNumber), + action: 'transfer' as const, + direction, + assets: [{ + assetType: 'native' as const, + value: tx.value, + symbol: this.symbol, + decimals: this.decimals, + }], } - - const normalizedAddress = address.toLowerCase() - - return parsed.data.result.result.map((tx): Transaction => { - const from = tx.from - const to = tx.to - const direction = this.getDirection(from, to, normalizedAddress) - - return { - hash: tx.hash, - from, - to, - timestamp: Number(tx.timeStamp) * 1000, - status: tx.isError === '1' ? 'failed' : 'confirmed', - blockNumber: BigInt(tx.blockNumber), - action: 'transfer' as const, - direction, - assets: [{ - assetType: 'native' as const, - value: tx.value, - symbol: this.symbol, - decimals: this.decimals, - }], - } - }) - } catch (error) { - console.warn('[BscWalletProvider] Error fetching transactions:', error) - return [] - } + }) } private getDirection(from: string, to: string, address: string): Direction { @@ -150,10 +155,10 @@ export class BscWalletProvider implements ApiProvider { * 工厂函数:识别 bscwallet-v1 类型的 API entry */ export function createBscWalletProvider(entry: ParsedApiEntry, chainId: string): BscWalletProvider | null { - if (entry.key !== 'bscwallet-v1') return null + if (entry.type !== 'bscwallet-v1') return null const decimals = chainConfigService.getDecimals(chainId) const symbol = chainConfigService.getSymbol(chainId) - return new BscWalletProvider(entry, decimals, symbol) + return new BscWalletProvider(entry, chainId, decimals, symbol) } diff --git a/src/services/chain-adapter/providers/btcwallet-provider.ts b/src/services/chain-adapter/providers/btcwallet-provider.ts new file mode 100644 index 000000000..ade95255e --- /dev/null +++ b/src/services/chain-adapter/providers/btcwallet-provider.ts @@ -0,0 +1,195 @@ +import { z } from 'zod' +import type { ApiProvider, Balance, Transaction, Direction } from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' +import { fetchJson, observeValueAndInvalidate } from './fetch-json' + +const BlockbookErrorSchema = z.looseObject({ + error: z.string(), +}) + +const BlockbookVinSchema = z.looseObject({ + addresses: z.array(z.string()), + value: z.string(), +}) + +const BlockbookVoutSchema = z.looseObject({ + addresses: z.array(z.string()), + value: z.string(), +}) + +const BlockbookTxSchema = z.looseObject({ + txid: z.string(), + blockHeight: z.number(), + confirmations: z.number(), + blockTime: z.number(), + vin: z.array(BlockbookVinSchema), + vout: z.array(BlockbookVoutSchema), + fees: z.string().optional(), +}) + +const BlockbookAddressInfoSchema = z.looseObject({ + address: z.string().optional(), + balance: z.string(), + unconfirmedBalance: z.string().optional(), + txs: z.number().optional(), + transactions: z.array(BlockbookTxSchema).optional(), +}) + +function sumValues(items: Array<{ addresses: string[]; value: string }>, address: string): bigint { + let sum = 0n + for (const item of items) { + if (item.addresses.some((a) => a === address)) { + sum += BigInt(item.value) + } + } + return sum +} + +function getDirection(net: bigint): Direction { + if (net > 0n) return 'in' + if (net < 0n) return 'out' + return 'self' +} + +export class BtcWalletProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + get supportsNativeBalance() { + return true + } + + get supportsTransactionHistory() { + return true + } + + async getNativeBalance(address: string): Promise { + const info = await this.proxyGet(`/api/v2/address/${address}`, BlockbookAddressInfoSchema, { + cacheKey: `btcwallet:${this.chainId}:address:${address}`, + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }) + + const confirmed = BigInt(info.balance) + const unconfirmed = BigInt(info.unconfirmedBalance ?? '0') + const total = confirmed + unconfirmed + + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: total.toString(), + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) + + return { + amount: Amount.fromRaw(total.toString(), this.decimals, this.symbol), + symbol: this.symbol, + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + const info = await this.proxyGet( + `/api/v2/address/${address}?details=txs&page=1&pageSize=${limit}`, + BlockbookAddressInfoSchema, + { + cacheKey: `btcwallet:${this.chainId}:txs:${address}:${limit}`, + ttlMs: 5 * 60_000, + tags: [`txhistory:${this.chainId}:${address}`], + }, + ) + + const txs = info.transactions ?? [] + const results: Transaction[] = txs.map((tx) => { + const inSum = sumValues(tx.vin, address) + const outSum = sumValues(tx.vout, address) + const net = outSum - inSum + const direction = getDirection(net) + const value = net < 0n ? (-net).toString() : net.toString() + + const from = direction === 'in' + ? (tx.vin[0]?.addresses?.[0] ?? '') + : address + + const toCandidate = tx.vout + .flatMap((v) => v.addresses) + .find((a) => a !== address) + const to = direction === 'out' + ? (toCandidate ?? address) + : address + + return { + hash: tx.txid, + from, + to, + timestamp: tx.blockTime * 1000, + status: tx.confirmations > 0 ? 'confirmed' : 'pending', + blockNumber: tx.blockHeight > 0 ? BigInt(tx.blockHeight) : undefined, + action: 'transfer', + direction, + assets: [ + { + assetType: 'native' as const, + value, + symbol: this.symbol, + decimals: this.decimals, + }, + ], + } + }) + + return results.sort((a, b) => b.timestamp - a.timestamp) + } + + private async proxyGet( + path: string, + schema: z.ZodType, + options?: { cacheKey?: string; ttlMs?: number; tags?: string[] }, + ): Promise { + const json: unknown = await fetchJson( + this.endpoint, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: path, method: 'GET' }), + }, + { + cacheKey: options?.cacheKey, + ttlMs: options?.ttlMs, + tags: options?.tags, + }, + ) + + const err = BlockbookErrorSchema.safeParse(json) + if (err.success && err.data.error) { + throw new Error(err.data.error) + } + + const parsed = schema.safeParse(json) + if (!parsed.success) { + throw new Error('Invalid API response') + } + return parsed.data + } +} + +export function createBtcwalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === 'btcwallet-v1') { + return new BtcWalletProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts index 14142b6e2..8b0d95daa 100644 --- a/src/services/chain-adapter/providers/chain-provider.ts +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -16,6 +16,19 @@ import type { UnsignedTransaction, SignedTransaction, } from './types' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' + +const SYNC_METHODS = new Set(['isValidAddress', 'normalizeAddress']) + +const READ_METHODS = new Set([ + 'getNativeBalance', + 'getTokenBalances', + 'getTransactionHistory', + 'getTransaction', + 'getTransactionStatus', + 'getBlockHeight', +]) export class ChainProvider { readonly chainId: string @@ -34,14 +47,73 @@ export class ChainProvider { } /** - * 查找实现了某方法的 Provider,返回绑定好的方法 + * 查找实现了某方法的 Provider,返回自动 fallback 的方法 */ private getMethod(method: K): ApiProvider[K] | undefined { - const provider = this.providers.find(p => typeof p[method] === 'function') - if (!provider) return undefined - const fn = provider[method] - if (typeof fn !== 'function') return undefined - return fn.bind(provider) as ApiProvider[K] + const candidates = this.providers.filter((p) => typeof p[method] === 'function') + if (candidates.length === 0) return undefined + + const first = candidates[0] + const firstFn = first[method] + if (typeof firstFn !== 'function') return undefined + + if (SYNC_METHODS.has(method)) { + return firstFn.bind(first) as ApiProvider[K] + } + + const fn = (async (...args: unknown[]) => { + let lastError: unknown = null + + for (const provider of candidates) { + const impl = provider[method] + if (typeof impl !== 'function') continue + try { + return await (impl as (...args: unknown[]) => Promise).apply(provider, args) + } catch (error) { + lastError = error + } + } + + if (READ_METHODS.has(method)) { + return this.getReadDefault(method) + } + + throw lastError instanceof Error ? lastError : new Error('All providers failed') + }) as ApiProvider[K] + + return fn + } + + private getReadDefault(method: ApiProviderMethod): unknown { + switch (method) { + case 'getNativeBalance': { + const decimals = chainConfigService.getDecimals(this.chainId) + const symbol = chainConfigService.getSymbol(this.chainId) + const balance: Balance = { + amount: Amount.zero(decimals, symbol), + symbol, + } + return balance + } + case 'getTokenBalances': + return [] as TokenBalance[] + case 'getTransactionHistory': + return [] as Transaction[] + case 'getTransaction': + return null as Transaction | null + case 'getTransactionStatus': { + const status: TransactionStatus = { + status: 'pending', + confirmations: 0, + requiredConfirmations: 1, + } + return status + } + case 'getBlockHeight': + return 0n + default: + return null + } } // ===== 便捷属性:检查查询能力 ===== diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-provider.ts index d85abd167..d07caee2a 100644 --- a/src/services/chain-adapter/providers/etherscan-provider.ts +++ b/src/services/chain-adapter/providers/etherscan-provider.ts @@ -10,6 +10,7 @@ import { z } from 'zod' 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' /** EVM Chain IDs */ const EVM_CHAIN_IDS: Record = { @@ -84,27 +85,19 @@ export class EtherscanProvider implements ApiProvider { } async getTransactionHistory(address: string, limit = 20): Promise { - try { - const fetchLimit = limit * FETCH_MULTIPLIER - - // 并行获取原生交易和代币交易(拉取更多以应对分页陷阱) - const [nativeTxs, tokenTxs] = await Promise.all([ - this.fetchNativeTransactions(address, fetchLimit), - this.fetchTokenTransactions(address, fetchLimit), - ]) - - // 按 hash 聚合:txlist 为主轴,tokentx 贴合 - const normalizedAddress = address.toLowerCase() - const aggregated = this.aggregateByHash(nativeTxs, tokenTxs, normalizedAddress) - - // 按时间排序、限制数量 - return aggregated - .sort((a, b) => b.timestamp - a.timestamp) - .slice(0, limit) - } catch (error) { - console.warn('[EtherscanProvider] Error fetching transactions:', error) - return [] - } + const fetchLimit = limit * FETCH_MULTIPLIER + + const [nativeTxs, tokenTxs] = await Promise.all([ + this.fetchNativeTransactions(address, fetchLimit), + this.fetchTokenTransactions(address, fetchLimit), + ]) + + const normalizedAddress = address.toLowerCase() + const aggregated = this.aggregateByHash(nativeTxs, tokenTxs, normalizedAddress) + + return aggregated + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit) } /** @@ -297,13 +290,9 @@ export class EtherscanProvider implements ApiProvider { private async fetchNativeTransactions(address: string, limit: number): Promise { const params = this.buildParams('txlist', address, limit) - const response = await this.fetchApi(params) - - if (!response.success || !Array.isArray(response.data)) { - return [] - } + const data = await this.fetchApi(params, `etherscan:${this.type}:txlist:${address}:${limit}`) - return response.data + return data .map(item => NativeTxSchema.safeParse(item)) .filter((r): r is z.SafeParseSuccess => r.success) .map(r => r.data) @@ -311,13 +300,9 @@ export class EtherscanProvider implements ApiProvider { private async fetchTokenTransactions(address: string, limit: number): Promise { const params = this.buildParams('tokentx', address, limit) - const response = await this.fetchApi(params) - - if (!response.success || !Array.isArray(response.data)) { - return [] - } + const data = await this.fetchApi(params, `etherscan:${this.type}:tokentx:${address}:${limit}`) - return response.data + return data .map(item => TokenTxSchema.safeParse(item)) .filter((r): r is z.SafeParseSuccess => r.success) .map(r => r.data) @@ -348,32 +333,36 @@ export class EtherscanProvider implements ApiProvider { return params } - private async fetchApi(params: URLSearchParams): Promise<{ success: boolean; data: unknown[] }> { - try { - const url = `${this.endpoint}?${params.toString()}` - const response = await fetch(url) - - if (!response.ok) { - console.warn(`[EtherscanProvider] HTTP ${response.status}`) - return { success: false, data: [] } - } + private async fetchApi(params: URLSearchParams, cacheKey: string): Promise { + const url = `${this.endpoint}?${params.toString()}` + const address = params.get('address') ?? '' + const json: unknown = await fetchJson(url, undefined, { + cacheKey, + ttlMs: 5 * 60_000, + tags: address ? [`txhistory:${this.chainId}:${address}`] : undefined, + }) - const json: unknown = await response.json() - const parsed = ApiResponseSchema.safeParse(json) - - if (!parsed.success || parsed.data.status !== '1') { - return { success: false, data: [] } - } + const parsed = ApiResponseSchema.safeParse(json) + if (!parsed.success) { + throw new Error('Invalid API response') + } - const result = parsed.data.result - if (!Array.isArray(result)) { - return { success: false, data: [] } - } + const status = parsed.data.status + const message = parsed.data.message.toLowerCase() + + if (status === '0' && message.includes('no transactions')) { + return [] + } + if (status !== '1') { + throw new Error('Upstream API error') + } - return { success: true, data: result } - } catch { - return { success: false, data: [] } + const result = parsed.data.result + if (!Array.isArray(result)) { + throw new Error('Invalid API result') } + + return result } private getDirection(from: string, to: string, address: string): Direction { diff --git a/src/services/chain-adapter/providers/ethwallet-provider.ts b/src/services/chain-adapter/providers/ethwallet-provider.ts new file mode 100644 index 000000000..467bfce44 --- /dev/null +++ b/src/services/chain-adapter/providers/ethwallet-provider.ts @@ -0,0 +1,284 @@ +import { z } from 'zod' +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, 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 NativeTxSchema = z.looseObject({ + blockNumber: z.string(), + timeStamp: z.string(), + hash: z.string(), + from: z.string(), + to: z.string(), + value: z.string(), + isError: z.string().optional(), + input: z.string().optional(), + methodId: z.string().optional(), + functionName: z.string().optional(), +}) + +const TokenTxSchema = z.looseObject({ + blockNumber: z.string(), + timeStamp: z.string(), + hash: z.string(), + from: z.string(), + to: z.string(), + value: z.string(), + tokenName: z.string(), + tokenSymbol: z.string(), + tokenDecimal: z.string(), + contractAddress: z.string(), +}) + +type NativeTx = z.infer +type TokenTx = z.infer + +const FETCH_MULTIPLIER = 5 + +function getDirection(from: string, to: string, normalizedAddress: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + + if (fromLower === normalizedAddress && toLower === normalizedAddress) return 'self' + if (fromLower === normalizedAddress) return 'out' + return 'in' +} + +function detectAction(tx: NativeTx): Action { + const value = tx.value + if (value && value !== '0') return 'transfer' + return 'contract' +} + +function selectPrimaryToken(tokenTxs: TokenTx[], normalizedAddress: string): TokenTx | null { + if (tokenTxs.length === 0) return null + if (tokenTxs.length === 1) return tokenTxs[0] + + const involving = tokenTxs.filter( + (t) => t.from.toLowerCase() === normalizedAddress || t.to.toLowerCase() === normalizedAddress, + ) + const candidates = involving.length > 0 ? involving : tokenTxs + + return candidates.reduce((max, t) => { + const maxVal = BigInt(max.value) + const tVal = BigInt(t.value) + return tVal > maxVal ? t : max + }) +} + +export class EthWalletProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + get supportsNativeBalance() { + return true + } + + get supportsTransactionHistory() { + return true + } + + async getNativeBalance(address: string): Promise { + const url = new URL(`${this.endpoint}/balance`) + url.searchParams.set('address', address) + + const json: unknown = await fetchJson(url.toString(), undefined, { + cacheKey: `ethwallet:${this.chainId}:balance:${address}`, + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }) + const parsed = BalanceResponseSchema.safeParse(json) + if (!parsed.success) { + throw new Error('Invalid API response') + } + + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: parsed.data, + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) + + return { + amount: Amount.fromRaw(parsed.data, this.decimals, this.symbol), + symbol: this.symbol, + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + const fetchLimit = limit * FETCH_MULTIPLIER + + const [nativeTxs, tokenTxs] = await Promise.all([ + this.fetchHistory(`${this.endpoint}/trans/normal/history`, address, fetchLimit, NativeTxSchema, 'normal'), + this.fetchHistory(`${this.endpoint}/trans/erc20/history`, address, fetchLimit, TokenTxSchema, 'erc20'), + ]) + + const normalizedAddress = address.toLowerCase() + const tokenTxsByHash = new Map() + for (const t of tokenTxs) { + const hash = t.hash.toLowerCase() + const existing = tokenTxsByHash.get(hash) ?? [] + existing.push(t) + tokenTxsByHash.set(hash, existing) + } + + const results: Transaction[] = [] + const processed = new Set() + + for (const ntx of nativeTxs) { + const hash = ntx.hash.toLowerCase() + if (processed.has(hash)) continue + processed.add(hash) + + const relatedTokens = tokenTxsByHash.get(hash) ?? [] + const primaryToken = selectPrimaryToken(relatedTokens, normalizedAddress) + + if (primaryToken) { + results.push({ + hash: primaryToken.hash, + from: primaryToken.from, + to: primaryToken.to, + timestamp: Number(primaryToken.timeStamp) * 1000, + status: 'confirmed', + blockNumber: BigInt(primaryToken.blockNumber), + action: 'transfer', + direction: getDirection(primaryToken.from, primaryToken.to, normalizedAddress), + assets: [ + { + assetType: 'token' as const, + value: primaryToken.value, + symbol: primaryToken.tokenSymbol, + decimals: parseInt(primaryToken.tokenDecimal, 10), + contractAddress: primaryToken.contractAddress, + name: primaryToken.tokenName, + }, + ], + contract: { + address: ntx.to, + method: ntx.functionName ?? undefined, + methodId: ntx.methodId ?? undefined, + }, + }) + continue + } + + results.push({ + hash: ntx.hash, + from: ntx.from, + to: ntx.to, + timestamp: Number(ntx.timeStamp) * 1000, + status: ntx.isError === '1' ? 'failed' : 'confirmed', + blockNumber: BigInt(ntx.blockNumber), + action: detectAction(ntx), + direction: getDirection(ntx.from, ntx.to, normalizedAddress), + assets: [ + { + assetType: 'native' as const, + value: ntx.value, + symbol: this.symbol, + decimals: this.decimals, + }, + ], + contract: ntx.value === '0' ? { + address: ntx.to, + method: ntx.functionName ?? undefined, + methodId: ntx.methodId ?? undefined, + } : undefined, + }) + } + + for (const ttx of tokenTxs) { + const hash = ttx.hash.toLowerCase() + if (processed.has(hash)) continue + processed.add(hash) + + results.push({ + hash: ttx.hash, + from: ttx.from, + to: ttx.to, + timestamp: Number(ttx.timeStamp) * 1000, + status: 'confirmed', + blockNumber: BigInt(ttx.blockNumber), + action: 'transfer', + direction: getDirection(ttx.from, ttx.to, normalizedAddress), + assets: [ + { + assetType: 'token' as const, + value: ttx.value, + symbol: ttx.tokenSymbol, + decimals: parseInt(ttx.tokenDecimal, 10), + contractAddress: ttx.contractAddress, + name: ttx.tokenName, + }, + ], + }) + } + + return results + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit) + } + + private async fetchHistory( + url: string, + address: string, + limit: number, + itemSchema: z.ZodType, + kind: string, + ): Promise { + const json: unknown = await fetchJson( + url, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, page: 1, offset: limit }), + }, + { + cacheKey: `ethwallet:${this.chainId}:${kind}:${address}:${limit}`, + ttlMs: 5 * 60_000, + tags: [`txhistory:${this.chainId}:${address}`], + }, + ) + + const parsed = HistoryResponseSchema.safeParse(json) + if (!parsed.success) { + throw new Error('Invalid API response') + } + + return parsed.data.result + .map((item) => itemSchema.safeParse(item)) + .filter((r): r is z.SafeParseSuccess => r.success) + .map((r) => r.data) + } +} + +export function createEthwalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === 'ethwallet-v1') { + return new EthWalletProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.ts b/src/services/chain-adapter/providers/evm-rpc-provider.ts index 6ce5d6b29..df938d61b 100644 --- a/src/services/chain-adapter/providers/evm-rpc-provider.ts +++ b/src/services/chain-adapter/providers/evm-rpc-provider.ts @@ -10,6 +10,7 @@ import type { ApiProvider, Balance } 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' const JsonRpcResponseSchema = z.looseObject({ result: z.unknown().optional(), @@ -39,22 +40,23 @@ export class EvmRpcProvider implements ApiProvider { } private async rpc(method: string, resultSchema: z.ZodType, params: unknown[] = []): Promise { - const response = await fetch(this.endpoint, { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }) + + const cacheable = method === 'eth_getBalance' || method === 'eth_blockNumber' + const json: unknown = await fetchJson(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: Date.now(), - method, - params, - }), + body, + }, { + cacheKey: `evm-rpc:${this.endpoint}:${method}:${JSON.stringify(params)}`, + ttlMs: cacheable ? 10_000 : 0, }) - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - - const json: unknown = await response.json() const parsed = JsonRpcResponseSchema.safeParse(json) if (!parsed.success) { throw new Error('Invalid JSON-RPC response') @@ -73,30 +75,18 @@ export class EvmRpcProvider implements ApiProvider { } async getNativeBalance(address: string): Promise { - try { - const balanceHex = await this.rpc('eth_getBalance', z.string(), [address, 'latest']) - const balanceWei = BigInt(balanceHex) - - return { - amount: Amount.fromRaw(balanceWei.toString(), this.decimals, this.symbol), - symbol: this.symbol, - } - } catch (error) { - console.warn('[EvmRpcProvider] Error fetching balance:', error) - return { - amount: Amount.zero(this.decimals, this.symbol), - symbol: this.symbol, - } + const balanceHex = await this.rpc('eth_getBalance', z.string(), [address, 'latest']) + const balanceWei = BigInt(balanceHex) + + return { + amount: Amount.fromRaw(balanceWei.toString(), this.decimals, this.symbol), + symbol: this.symbol, } } async getBlockHeight(): Promise { - try { - const blockHex = await this.rpc('eth_blockNumber', z.string()) - return BigInt(blockHex) - } catch { - return 0n - } + const blockHex = await this.rpc('eth_blockNumber', z.string()) + return BigInt(blockHex) } } diff --git a/src/services/chain-adapter/providers/fetch-json.ts b/src/services/chain-adapter/providers/fetch-json.ts new file mode 100644 index 000000000..b488c43b3 --- /dev/null +++ b/src/services/chain-adapter/providers/fetch-json.ts @@ -0,0 +1,108 @@ +export type FetchJsonOptions = { + /** Cache key; defaults to method+url+body (string bodies only). */ + cacheKey?: string + /** TTL for response JSON cache; 0/undefined disables caching. */ + ttlMs?: number + /** Tags for invalidation; only effective when ttlMs > 0. */ + tags?: string[] +} + +type CacheEntry = { + expiresAt: number + value: unknown +} + +const inFlight = new Map>() +const jsonCache = new Map() +const tagIndex = new Map>() + +const observedValues = new Map() + +function buildDefaultKey(url: string, init?: RequestInit): string { + const method = (init?.method ?? 'GET').toUpperCase() + const body = typeof init?.body === 'string' ? init.body : '' + return `${method}:${url}:${body}` +} + +function addTags(cacheKey: string, tags: string[]): void { + for (const tag of tags) { + const existing = tagIndex.get(tag) ?? new Set() + existing.add(cacheKey) + tagIndex.set(tag, existing) + } +} + +export function invalidateTags(tags: string[]): void { + for (const tag of tags) { + const keys = tagIndex.get(tag) + if (!keys) continue + for (const key of keys) { + jsonCache.delete(key) + } + tagIndex.delete(tag) + } +} + +/** + * Record a string value and invalidate tags if it changes. + * Useful for "balance changed => invalidate tx history" policies. + */ +export function observeValueAndInvalidate(options: { + key: string + value: string + invalidateTags: string[] +}): void { + const previous = observedValues.get(options.key) + observedValues.set(options.key, options.value) + + if (previous !== undefined && previous !== options.value) { + invalidateTags(options.invalidateTags) + } +} + +export async function fetchJson(url: string, init?: RequestInit, options?: FetchJsonOptions): Promise { + const ttlMs = options?.ttlMs ?? 0 + const cacheKey = options?.cacheKey ?? buildDefaultKey(url, init) + + if (ttlMs > 0) { + const cached = jsonCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.value as T + } + } + + const inflight = inFlight.get(cacheKey) + if (inflight) { + return (await inflight) as T + } + + const task = (async () => { + const response = await fetch(url, init) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + return (await response.json()) as unknown + })() + + inFlight.set(cacheKey, task) + + try { + const value = await task + if (ttlMs > 0) { + jsonCache.set(cacheKey, { value, expiresAt: Date.now() + ttlMs }) + if (options?.tags?.length) { + addTags(cacheKey, options.tags) + } + } + return value as T + } finally { + inFlight.delete(cacheKey) + } +} + +export function resetFetchJsonForTests(): void { + inFlight.clear() + jsonCache.clear() + tagIndex.clear() + observedValues.clear() +} diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index a1e2dfc3f..5e171c5e3 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -14,6 +14,9 @@ export { BiowalletProvider, createBiowalletProvider } from './biowallet-provider export { BscWalletProvider, createBscWalletProvider } from './bscwallet-provider' export { TronRpcProvider, createTronRpcProvider } from './tron-rpc-provider' export { MempoolProvider, createMempoolProvider } from './mempool-provider' +export { EthWalletProvider, createEthwalletProvider } from './ethwallet-provider' +export { TronWalletProvider, createTronwalletProvider } from './tronwallet-provider' +export { BtcWalletProvider, createBtcwalletProvider } from './btcwallet-provider' // Wrapped Provider 实现 export { WrappedTransactionProvider } from './wrapped-transaction-provider' @@ -31,6 +34,9 @@ import { createBiowalletProvider } from './biowallet-provider' import { createBscWalletProvider } from './bscwallet-provider' import { createTronRpcProvider } from './tron-rpc-provider' import { createMempoolProvider } from './mempool-provider' +import { createEthwalletProvider } from './ethwallet-provider' +import { createTronwalletProvider } from './tronwallet-provider' +import { createBtcwalletProvider } from './btcwallet-provider' import { WrappedTransactionProvider } from './wrapped-transaction-provider' import { WrappedIdentityProvider } from './wrapped-identity-provider' @@ -56,6 +62,9 @@ const PROVIDER_FACTORIES: ApiProviderFactory[] = [ createEvmRpcProvider, createTronRpcProvider, createMempoolProvider, + createEthwalletProvider, + createTronwalletProvider, + createBtcwalletProvider, ] /** diff --git a/src/services/chain-adapter/providers/mempool-provider.ts b/src/services/chain-adapter/providers/mempool-provider.ts index 5127b348c..1343754ba 100644 --- a/src/services/chain-adapter/providers/mempool-provider.ts +++ b/src/services/chain-adapter/providers/mempool-provider.ts @@ -9,6 +9,7 @@ import type { ApiProvider, Balance, Transaction, Direction } from './types' import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' +import { fetchJson, observeValueAndInvalidate } from './fetch-json' interface MempoolAddressInfo { address: string @@ -97,12 +98,11 @@ export class MempoolProvider implements ApiProvider { } private async api(path: string, schema: z.ZodType): Promise { - const response = await fetch(`${this.endpoint}${path}`) - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - - const json: unknown = await response.json() + const url = `${this.endpoint}${path}` + const json: unknown = await fetchJson(url, undefined, { + cacheKey: `mempool:${url}`, + ttlMs: 30_000, + }) const parsed = schema.safeParse(json) if (!parsed.success) { throw new Error('Invalid API response') @@ -111,32 +111,46 @@ export class MempoolProvider implements ApiProvider { } async getNativeBalance(address: string): Promise { - try { - const info = await this.api(`/address/${address}`, MempoolAddressInfoSchema) - - // 计算余额:已收到 - 已花费 - const confirmed = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum - const unconfirmed = info.mempool_stats.funded_txo_sum - info.mempool_stats.spent_txo_sum - const total = confirmed + unconfirmed - - return { - amount: Amount.fromRaw(total.toString(), this.decimals, this.symbol), - symbol: this.symbol, - } - } catch (error) { - console.warn('[MempoolProvider] Error fetching balance:', error) - return { - amount: Amount.zero(this.decimals, this.symbol), - symbol: this.symbol, - } + const url = `${this.endpoint}/address/${address}` + const info = await fetchJson(url, undefined, { + cacheKey: `mempool:balance:${address}`, + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }).then((json) => { + const parsed = MempoolAddressInfoSchema.safeParse(json) + if (!parsed.success) throw new Error('Invalid API response') + return parsed.data + }) + + const confirmed = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum + const unconfirmed = info.mempool_stats.funded_txo_sum - info.mempool_stats.spent_txo_sum + const total = confirmed + unconfirmed + + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: total.toString(), + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) + + return { + amount: Amount.fromRaw(total.toString(), this.decimals, this.symbol), + symbol: this.symbol, } } async getTransactionHistory(address: string, limit = 20): Promise { - try { - const txs = await this.api(`/address/${address}/txs`, z.array(MempoolTxSchema)) - - return txs.slice(0, limit).map((tx): Transaction => { + const url = `${this.endpoint}/address/${address}/txs` + const txs = await fetchJson(url, undefined, { + cacheKey: `mempool:txs:${address}`, + ttlMs: 5 * 60_000, + tags: [`txhistory:${this.chainId}:${address}`], + }).then((json) => { + const parsed = z.array(MempoolTxSchema).safeParse(json) + if (!parsed.success) throw new Error('Invalid API response') + return parsed.data + }) + + return txs.slice(0, limit).map((tx): Transaction => { // 判断是发送还是接收 const isOutgoing = tx.vin.some(vin => vin.prevout?.scriptpubkey_address === address @@ -186,19 +200,11 @@ export class MempoolProvider implements ApiProvider { }], } }) - } catch (error) { - console.warn('[MempoolProvider] Error fetching transactions:', error) - return [] - } } async getBlockHeight(): Promise { - try { - const height = await this.api('/blocks/tip/height', z.number()) - return BigInt(height) - } catch { - return 0n - } + const height = await this.api('/blocks/tip/height', z.number()) + return BigInt(height) } } diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.ts b/src/services/chain-adapter/providers/tron-rpc-provider.ts index 274909d82..37548aaef 100644 --- a/src/services/chain-adapter/providers/tron-rpc-provider.ts +++ b/src/services/chain-adapter/providers/tron-rpc-provider.ts @@ -9,6 +9,25 @@ import type { ApiProvider, Balance, Transaction, Direction } from './types' import type { ParsedApiEntry } from '@/services/chain-config' import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' +import { fetchJson, observeValueAndInvalidate } from './fetch-json' + +function readEnvValue(key: string): string | undefined { + try { + const fromImportMeta = (import.meta as any)?.env?.[key] + if (typeof fromImportMeta === 'string' && fromImportMeta.length > 0) return fromImportMeta + } catch { + // ignore + } + + try { + const fromProcess = (process as any)?.env?.[key] + if (typeof fromProcess === 'string' && fromProcess.length > 0) return fromProcess + } catch { + // ignore + } + + return undefined +} interface TronAccountResponse { balance?: number @@ -110,18 +129,32 @@ export class TronRpcProvider implements ApiProvider { this.decimals = chainConfigService.getDecimals(chainId) } - private async api(path: string, schema: z.ZodType, body?: unknown): Promise { + private async api(path: string, schema: z.ZodType, body?: unknown, options?: { ttlMs?: number; tags?: string[] }): Promise { const url = `${this.endpoint}${path}` - const init: RequestInit = body - ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } - : { method: 'GET' } - const response = await fetch(url, init) - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) + const headers: Record = {} + if (body !== undefined) { + 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 + } } - const json: unknown = await response.json() + const init: RequestInit = body !== undefined + ? { method: 'POST', headers, body: JSON.stringify(body) } + : { method: 'GET', headers } + + const json: unknown = await fetchJson(url, init, { + cacheKey: `tron:${this.type}:${url}:${typeof init.body === 'string' ? init.body : ''}`, + ttlMs: options?.ttlMs ?? 0, + tags: options?.ttlMs ? options?.tags : undefined, + }) + const parsed = schema.safeParse(json) if (!parsed.success) { throw new Error('Invalid API response') @@ -130,60 +163,48 @@ export class TronRpcProvider implements ApiProvider { } async getNativeBalance(address: string): Promise { - try { - // Tron 地址需要转换为 hex 格式或使用 base58 - const account = await this.api('/wallet/getaccount', TronAccountSchema, { - address, - visible: true, - }) + // Tron 地址需要转换为 hex 格式或使用 base58 + const account = await this.api('/wallet/getaccount', TronAccountSchema, { + address, + visible: true, + }, { + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }) - const balanceSun = account.balance ?? 0 - - return { - amount: Amount.fromRaw(balanceSun.toString(), this.decimals, this.symbol), - symbol: this.symbol, - } - } catch (error) { - console.warn('[TronRpcProvider] Error fetching balance:', error) - return { - amount: Amount.zero(this.decimals, this.symbol), - symbol: this.symbol, - } + const balanceSun = account.balance ?? 0 + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: balanceSun.toString(), + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) + + return { + amount: Amount.fromRaw(balanceSun.toString(), this.decimals, this.symbol), + symbol: this.symbol, } } async getBlockHeight(): Promise { - try { - const block = await this.api('/wallet/getnowblock', TronNowBlockSchema) - const height = block.block_header?.raw_data?.number ?? 0 - return BigInt(height) - } catch { - return 0n - } + const block = await this.api('/wallet/getnowblock', TronNowBlockSchema, undefined, { ttlMs: 10_000 }) + const height = block.block_header?.raw_data?.number ?? 0 + return BigInt(height) } async getTransactionHistory(address: string, limit = 20): Promise { - try { - const fetchLimit = limit * FETCH_MULTIPLIER - - // 并行获取原生交易和 TRC-20 交易 - const [nativeTxs, trc20Txs] = await Promise.all([ - this.fetchNativeTransactions(address, fetchLimit), - this.fetchTrc20Transactions(address, fetchLimit), - ]) - - // 按 txID 聚合 - const normalizedAddress = address.toLowerCase() - const aggregated = this.aggregateByTxId(nativeTxs, trc20Txs, normalizedAddress) - - // 按时间排序、限制数量 - return aggregated - .sort((a, b) => b.timestamp - a.timestamp) - .slice(0, limit) - } catch (error) { - console.warn('[TronRpcProvider] Error fetching transaction history:', error) - return [] - } + const fetchLimit = limit * FETCH_MULTIPLIER + + const [nativeTxs, trc20Txs] = await Promise.all([ + this.fetchNativeTransactions(address, fetchLimit), + this.fetchTrc20Transactions(address, fetchLimit), + ]) + + const normalizedAddress = address.toLowerCase() + const aggregated = this.aggregateByTxId(nativeTxs, trc20Txs, normalizedAddress) + + return aggregated + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit) } /** @@ -472,27 +493,29 @@ export class TronRpcProvider implements ApiProvider { } private async fetchNativeTransactions(address: string, limit: number): Promise { - try { - const result = await this.api( - `/v1/accounts/${address}/transactions?limit=${limit}`, - TronTxListSchema - ) - return result.success && result.data ? result.data : [] - } catch { - return [] + const result = await this.api( + `/v1/accounts/${address}/transactions?limit=${limit}`, + TronTxListSchema, + undefined, + { ttlMs: 5 * 60_000, tags: [`txhistory:${this.chainId}:${address}`] }, + ) + if (!result.success) { + throw new Error('Tron API error') } + return result.data ?? [] } private async fetchTrc20Transactions(address: string, limit: number): Promise { - try { - const result = await this.api( - `/v1/accounts/${address}/transactions/trc20?limit=${limit}`, - Trc20TxListSchema - ) - return result.success && result.data ? result.data : [] - } catch { - return [] + const result = await this.api( + `/v1/accounts/${address}/transactions/trc20?limit=${limit}`, + Trc20TxListSchema, + undefined, + { ttlMs: 5 * 60_000, tags: [`txhistory:${this.chainId}:${address}`] }, + ) + if (!result.success) { + throw new Error('Tron API error') } + return result.data ?? [] } private getDirection(from: string, to: string, address: string): Direction { @@ -511,7 +534,7 @@ export class TronRpcProvider implements ApiProvider { /** 工厂函数 */ export function createTronRpcProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { - if (entry.type === 'tron-rpc' || entry.type.startsWith('tron-')) { + if (entry.type === 'tron-rpc' || entry.type === 'tron-rpc-pro') { return new TronRpcProvider(entry, chainId) } return null diff --git a/src/services/chain-adapter/providers/tronwallet-provider.ts b/src/services/chain-adapter/providers/tronwallet-provider.ts new file mode 100644 index 000000000..d940f8069 --- /dev/null +++ b/src/services/chain-adapter/providers/tronwallet-provider.ts @@ -0,0 +1,330 @@ +import { z } from 'zod' +import type { ApiProvider, Balance, Transaction, Direction } from './types' +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 { sha256 } from '@noble/hashes/sha2.js' +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 TronHistorySchema = z.looseObject({ + data: z.array(z.unknown()), + success: z.boolean(), + fingerprint: z.string().optional(), +}) + +const TronNativeTxSchema = z.looseObject({ + contractRet: z.string().optional(), + txID: z.string(), + blockNumber: z.number().optional(), + from: z.string(), + to: z.string(), + amount: z.number(), + timestamp: z.number(), +}) + +const TronTrc20TxSchema = z.looseObject({ + txID: z.string(), + from: z.string(), + to: z.string(), + value: z.string(), + token_symbol: z.string(), + token_address: z.string(), + token_name: z.string(), + token_decimals: z.number(), + timestamp: z.number(), +}) + +type TronNativeTx = z.infer +type TronTrc20Tx = z.infer + +const FETCH_MULTIPLIER = 5 + +function decodeBase58(str: string): Uint8Array { + let num = BigInt(0) + for (const char of str) { + const index = ALPHABET.indexOf(char) + if (index === -1) throw new Error('Invalid base58') + num = num * 58n + BigInt(index) + } + + const hex = num.toString(16).padStart(50, '0') + const bytes = new Uint8Array(25) + for (let i = 0; i < 25; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +function encodeBase58(bytes: Uint8Array): string { + let num = BigInt(0) + for (const byte of bytes) { + num = num * 256n + BigInt(byte) + } + + let result = '' + while (num > 0n) { + const rem = Number(num % 58n) + num = num / 58n + result = ALPHABET[rem] + result + } + + for (const byte of bytes) { + if (byte === 0) result = ALPHABET[0] + result + else break + } + + return result +} + +function tronBase58ToHex(address: string): string { + const decoded = decodeBase58(address) + const payload = decoded.slice(0, 21) + return bytesToHex(payload) +} + +function tronHexToBase58(hexAddress: string): string { + const raw = hexAddress.startsWith('0x') ? hexAddress.slice(2) : hexAddress + const payload = hexToBytes(raw) + const checksum = sha256(sha256(payload)).slice(0, 4) + const full = new Uint8Array([...payload, ...checksum]) + return encodeBase58(full) +} + +function toTronBase58Address(address: string): string { + return address.startsWith('T') ? address : tronHexToBase58(address) +} + +function getDirection(from: string, to: string, normalizedAddress: string): Direction { + const fromLower = from.toLowerCase() + const toLower = to.toLowerCase() + + if (fromLower === normalizedAddress && toLower === normalizedAddress) return 'self' + if (fromLower === normalizedAddress) return 'out' + return 'in' +} + +export class TronWalletProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + get supportsNativeBalance() { + return true + } + + get supportsTransactionHistory() { + return true + } + + async getNativeBalance(address: string): Promise { + const hexAddress = address.startsWith('T') ? tronBase58ToHex(address) : address + const url = new URL(`${this.endpoint}/balance`) + url.searchParams.set('address', hexAddress) + + const json: unknown = await fetchJson(url.toString(), undefined, { + cacheKey: `tronwallet:${this.chainId}:balance:${address}`, + ttlMs: 60_000, + tags: [`balance:${this.chainId}:${address}`], + }) + const parsed = BalanceResponseSchema.safeParse(json) + if (!parsed.success) { + throw new Error('Invalid API response') + } + + observeValueAndInvalidate({ + key: `balance:${this.chainId}:${address}`, + value: parsed.data, + invalidateTags: [`txhistory:${this.chainId}:${address}`], + }) + + return { + amount: Amount.fromRaw(parsed.data, this.decimals, this.symbol), + symbol: this.symbol, + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + const fetchLimit = limit * FETCH_MULTIPLIER + const hexAddress = address.startsWith('T') ? tronBase58ToHex(address) : address + + const [nativeTxs, trc20Txs] = await Promise.all([ + this.fetchHistory( + `${this.endpoint}/trans/common/history`, + hexAddress, + address, + fetchLimit, + TronNativeTxSchema, + 'common', + ), + this.fetchHistory( + `${this.endpoint}/trans/trc20/history`, + hexAddress, + address, + fetchLimit, + TronTrc20TxSchema, + 'trc20', + ), + ]) + + const normalizedAddress = address.toLowerCase() + const tokenById = new Map() + for (const t of trc20Txs) { + const id = t.txID.toLowerCase() + const existing = tokenById.get(id) ?? [] + existing.push(t) + tokenById.set(id, existing) + } + + const results: Transaction[] = [] + const processed = new Set() + + for (const tx of nativeTxs) { + const id = tx.txID.toLowerCase() + if (processed.has(id)) continue + processed.add(id) + + const relatedTokens = tokenById.get(id) ?? [] + if (relatedTokens.length > 0) { + const primary = relatedTokens[0] + const from = toTronBase58Address(primary.from) + const to = toTronBase58Address(primary.to) + results.push({ + hash: primary.txID, + from, + to, + timestamp: primary.timestamp, + status: 'confirmed', + blockNumber: tx.blockNumber !== undefined ? BigInt(tx.blockNumber) : undefined, + action: 'transfer', + direction: getDirection(from, to, normalizedAddress), + assets: [ + { + assetType: 'token' as const, + value: primary.value, + symbol: primary.token_symbol, + decimals: primary.token_decimals, + contractAddress: toTronBase58Address(primary.token_address), + name: primary.token_name, + }, + ], + }) + continue + } + + const from = toTronBase58Address(tx.from) + const to = toTronBase58Address(tx.to) + results.push({ + hash: tx.txID, + from, + to, + timestamp: tx.timestamp, + status: 'confirmed', + blockNumber: tx.blockNumber !== undefined ? BigInt(tx.blockNumber) : undefined, + action: 'transfer', + direction: getDirection(from, to, normalizedAddress), + assets: [ + { + assetType: 'native' as const, + value: String(tx.amount), + symbol: this.symbol, + decimals: this.decimals, + }, + ], + }) + } + + for (const tx of trc20Txs) { + const id = tx.txID.toLowerCase() + if (processed.has(id)) continue + processed.add(id) + + const from = toTronBase58Address(tx.from) + const to = toTronBase58Address(tx.to) + results.push({ + hash: tx.txID, + from, + to, + timestamp: tx.timestamp, + status: 'confirmed', + action: 'transfer', + direction: getDirection(from, to, normalizedAddress), + assets: [ + { + assetType: 'token' as const, + value: tx.value, + symbol: tx.token_symbol, + decimals: tx.token_decimals, + contractAddress: toTronBase58Address(tx.token_address), + name: tx.token_name, + }, + ], + }) + } + + return results + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit) + } + + private async fetchHistory( + url: string, + hexAddress: string, + tagAddress: string, + limit: number, + itemSchema: z.ZodType, + kind: string, + ): Promise { + const json: unknown = await fetchJson( + url, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: hexAddress, limit }), + }, + { + cacheKey: `tronwallet:${this.chainId}:${kind}:${hexAddress}:${limit}`, + ttlMs: 5 * 60_000, + tags: [`txhistory:${this.chainId}:${tagAddress}`], + }, + ) + + const parsed = TronHistorySchema.safeParse(json) + if (!parsed.success || !parsed.data.success) { + throw new Error('Upstream API error') + } + + return parsed.data.data + .map((item) => itemSchema.safeParse(item)) + .filter((r): r is z.SafeParseSuccess => r.success) + .map((r) => r.data) + } +} + +export function createTronwalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === 'tronwallet-v1') { + return new TronWalletProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index 2037dde90..d52f53837 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -1,4 +1,4 @@ -export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainKind, ParsedApiEntry, ApiEntry, ApiConfig } from './types' +export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainKind, ParsedApiEntry, ApiProviderEntry, ApiProviders } from './types' export { chainConfigService } from './service' import { ChainConfigListSchema, ChainConfigSchema, ChainConfigSubscriptionSchema, VersionedChainConfigFileSchema } from './schema' diff --git a/src/services/chain-config/schema.ts b/src/services/chain-config/schema.ts index 0b2359474..b8e1e0920 100644 --- a/src/services/chain-config/schema.ts +++ b/src/services/chain-config/schema.ts @@ -3,7 +3,7 @@ * * 设计原则:分离链属性与提供商配置 * - 链属性:id, chainKind, name, symbol, prefix, decimals(链的固有属性) - * - 提供商配置:api, explorer(外部依赖,可替换) + * - 提供商配置:apis, explorer(外部依赖,可替换) * * 说明: * - `version` 使用 `major.minor`(例如 "1.0") @@ -22,26 +22,24 @@ export const ChainKindSchema = z.enum(['bioforest', 'evm', 'bitcoin', 'tron']) export const ChainConfigSourceSchema = z.enum(['default', 'subscription', 'manual']) /** - * API 提供商配置 + * API 提供商配置(有序) * - * 格式: Record - * - providerType: "{provider}-{version}" (e.g., "ethereum-rpc", "etherscan-v2", "biowallet-v1") - * - url: 简单 URL 字符串 - * - [url, config]: URL + 额外配置对象 + * 格式: apis: Array<{ type, endpoint, config? }> + * - type: "{provider}-{version}" (e.g., "ethereum-rpc", "blockscout-v1", "biowallet-v1") + * - endpoint: URL + * - config: 额外配置(可选) * - * 示例: - * { - * "ethereum-rpc": "https://ethereum-rpc.publicnode.com", - * "etherscan-v2": ["https://api.etherscan.io/v2/api", { "apiKey": "xxx" }], - * "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "bfm" }] - * } + * 说明:数组顺序即 fallback 顺序,越靠前优先级越高。 */ -export const ApiEntrySchema = z.union([ - z.string().url(), - z.tuple([z.string().url(), z.record(z.string(), z.unknown())]), -]) +export const ApiProviderEntrySchema = z + .object({ + type: z.string().min(1), + endpoint: z.string().url(), + config: z.record(z.string(), z.unknown()).optional(), + }) + .strict() -export const ApiConfigSchema = z.record(z.string(), ApiEntrySchema) +export const ApiProvidersSchema = z.array(ApiProviderEntrySchema) /** 区块浏览器配置(可替换的外部依赖) */ export const ExplorerConfigSchema = z.object({ @@ -73,7 +71,7 @@ export const ChainConfigSchema = z decimals: z.number().int().min(0).max(18), // ===== 提供商配置(外部依赖) ===== - api: ApiConfigSchema.optional(), + apis: ApiProvidersSchema.optional(), explorer: ExplorerConfigSchema.optional(), // ===== 运行时字段 ===== diff --git a/src/services/chain-config/service.ts b/src/services/chain-config/service.ts index 532d9a935..2d3689925 100644 --- a/src/services/chain-config/service.ts +++ b/src/services/chain-config/service.ts @@ -6,7 +6,7 @@ */ import { chainConfigStore, chainConfigSelectors } from '@/stores/chain-config' -import type { ApiEntry, ChainConfig, ParsedApiEntry } from './types' +import type { ChainConfig, ParsedApiEntry } from './types' class ChainConfigService { /** @@ -22,13 +22,7 @@ class ChainConfigService { */ getApi(chainId: string): ParsedApiEntry[] { const config = this.getConfig(chainId) - if (!config?.api) return [] - - const entries: ParsedApiEntry[] = [] - for (const [type, entry] of Object.entries(config.api)) { - entries.push(this.parseApiEntry(type, entry as ApiEntry)) - } - return entries + return config?.apis ?? [] } /** @@ -48,17 +42,6 @@ class ChainConfigService { return entries.find((e) => regex.test(e.type)) ?? null } - /** - * 解析 API 配置项 - */ - private parseApiEntry(type: string, entry: ApiEntry): ParsedApiEntry { - if (typeof entry === 'string') { - return { type, endpoint: entry } - } - const [endpoint, config] = entry - return { type, endpoint, config } - } - // ========== 便捷方法 (用于 Adapter) ========== /** diff --git a/src/services/chain-config/storage.ts b/src/services/chain-config/storage.ts index 00ee5da14..df3925f8d 100644 --- a/src/services/chain-config/storage.ts +++ b/src/services/chain-config/storage.ts @@ -126,7 +126,7 @@ export async function saveChainConfigs(options: { decimals: config.decimals, ...(config.icon !== undefined ? { icon: config.icon } : {}), ...(config.prefix !== undefined ? { prefix: config.prefix } : {}), - ...(config.api !== undefined ? { api: config.api } : {}), + ...(config.apis !== undefined ? { apis: config.apis } : {}), ...(config.explorer !== undefined ? { explorer: config.explorer } : {}), } diff --git a/src/services/chain-config/types.ts b/src/services/chain-config/types.ts index f050dcc75..e6bc94718 100644 --- a/src/services/chain-config/types.ts +++ b/src/services/chain-config/types.ts @@ -7,8 +7,8 @@ import type { z } from 'zod' import { - ApiConfigSchema, - ApiEntrySchema, + ApiProviderEntrySchema, + ApiProvidersSchema, ChainConfigListSchema, ChainConfigSchema, ChainConfigSourceSchema, @@ -25,14 +25,9 @@ export type ChainConfig = z.infer export type ChainConfigList = z.infer export type ChainConfigSubscription = z.infer -/** API 配置项 (url | [url, config]) */ -export type ApiEntry = z.infer -/** API 配置对象 Record */ -export type ApiConfig = z.infer +/** API provider entry (ordered) */ +export type ApiProviderEntry = z.infer +export type ApiProviders = z.infer -/** 解析后的 API 条目,用于 Provider 初始化 */ -export interface ParsedApiEntry { - type: string - endpoint: string - config?: Record -} +/** provider 初始化需要的条目(当前与 ApiProviderEntry 结构一致) */ +export type ParsedApiEntry = ApiProviderEntry