From 6318f70ec8555721240f39208ee8f46cc1509f80 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 17:49:05 +0800 Subject: [PATCH 01/27] feat: add address balance and transaction lookup pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AddressBalanceActivity for querying any address balance - Add AddressTransactionsActivity with explorer links - Add menu button (⋮) to WalletCardCarousel for quick access - Add useAddressBalanceQuery hook for balance queries - Add i18n translations for zh-CN and en This helps diagnose customer issues by allowing balance queries for any address without importing the wallet. --- .../wallet/wallet-card-carousel.tsx | 55 ++++-- src/i18n/locales/en/common.json | 15 ++ src/i18n/locales/zh-CN/common.json | 15 ++ src/pages/address-balance/index.tsx | 154 ++++++++++++++++ src/pages/address-transactions/index.tsx | 165 ++++++++++++++++++ src/queries/index.ts | 6 + src/queries/use-address-balance-query.ts | 63 +++++++ .../activities/AddressBalanceActivity.tsx | 11 ++ .../AddressTransactionsActivity.tsx | 11 ++ src/stackflow/activities/tabs/WalletTab.tsx | 12 ++ src/stackflow/stackflow.ts | 6 + 11 files changed, 503 insertions(+), 10 deletions(-) create mode 100644 src/pages/address-balance/index.tsx create mode 100644 src/pages/address-transactions/index.tsx create mode 100644 src/queries/use-address-balance-query.ts create mode 100644 src/stackflow/activities/AddressBalanceActivity.tsx create mode 100644 src/stackflow/activities/AddressTransactionsActivity.tsx diff --git a/src/components/wallet/wallet-card-carousel.tsx b/src/components/wallet/wallet-card-carousel.tsx index 3da8b3e4..40b95161 100644 --- a/src/components/wallet/wallet-card-carousel.tsx +++ b/src/components/wallet/wallet-card-carousel.tsx @@ -8,7 +8,13 @@ import { useWalletTheme } from '@/hooks/useWalletTheme'; import { useChainIconUrls } from '@/hooks/useChainIconUrls'; import { cn } from '@/lib/utils'; import type { Wallet, ChainType } from '@/stores'; -import { IconWallet, IconPlus } from '@tabler/icons-react'; +import { IconWallet, IconPlus, IconDotsVertical, IconSearch, IconReceipt } from '@tabler/icons-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import 'swiper/css'; import 'swiper/css/effect-cards'; @@ -26,6 +32,8 @@ interface WalletCardCarouselProps { onOpenSettings?: (walletId: string) => void; onOpenWalletList?: () => void; onAddWallet?: () => void; + onOpenAddressBalance?: () => void; + onOpenAddressTransactions?: () => void; className?: string; } @@ -45,6 +53,8 @@ export function WalletCardCarousel({ onOpenSettings, onOpenWalletList, onAddWallet, + onOpenAddressBalance, + onOpenAddressTransactions, className, }: WalletCardCarouselProps) { const swiperRef = useRef(null); @@ -111,15 +121,40 @@ export function WalletCardCarousel({ )} - {/* 右上角:添加钱包 */} - {onAddWallet && ( - - )} + {/* 右上角:添加钱包 + 更多菜单 */} +
+ {onAddWallet && ( + + )} + {(onOpenAddressBalance || onOpenAddressTransactions) && ( + + + + + + {onOpenAddressBalance && ( + + + 地址余额查询 + + )} + {onOpenAddressTransactions && ( + + + 地址交易查询 + + )} + + + )} +
{ + if (address.trim()) { + setQueryAddress(address.trim()) + setQueryChain(selectedChain) + } + }, [address, selectedChain]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch() + } + }, + [handleSearch] + ) + + const evmChains = enabledChains.filter((c) => c.type === 'evm') + const otherChains = enabledChains.filter((c) => c.type !== 'evm') + + return ( +
+ + +
+ {/* Chain Selector */} +
+ + +
+ + {/* Address Input */} +
+ +
+ setAddress(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 font-mono text-sm" + /> + +
+
+ + {/* Result */} + {queryAddress && ( + + + {data?.error ? ( +
+ +
+
{t('common:addressLookup.error')}
+
{data.error}
+
+
+ ) : data?.balance ? ( +
+
+ +
+
+
+ {data.balance.amount.toFormatted()} {data.balance.symbol} +
+
+ {t('common:addressLookup.onChain', { + chain: enabledChains.find((c) => c.id === queryChain)?.name ?? queryChain, + })} +
+
+
+ ) : isLoading ? ( +
+ +
+ ) : null} +
+
+ )} + + {/* Debug Info (DEV only) */} + {import.meta.env.DEV && queryAddress && ( +
+
Chain: {queryChain}
+
Address: {queryAddress}
+
+ )} +
+
+ ) +} diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx new file mode 100644 index 00000000..1c8c1b8f --- /dev/null +++ b/src/pages/address-transactions/index.tsx @@ -0,0 +1,165 @@ +import { useState, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigation } from '@/stackflow' +import { PageHeader } from '@/components/layout/page-header' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Card, CardContent } from '@/components/ui/card' +import { useEnabledChains } from '@/stores' +import { IconSearch, IconExternalLink, IconInfoCircle } from '@tabler/icons-react' + +export function AddressTransactionsPage() { + const { t } = useTranslation(['common', 'wallet']) + const { goBack } = useNavigation() + const enabledChains = useEnabledChains() + + const [selectedChain, setSelectedChain] = useState('ethereum') + const [address, setAddress] = useState('') + + const selectedChainConfig = useMemo( + () => enabledChains.find((c) => c.id === selectedChain), + [enabledChains, selectedChain] + ) + + const explorerUrl = useMemo(() => { + if (!selectedChainConfig?.explorer || !address.trim()) return null + + const { queryAddress, url } = selectedChainConfig.explorer + if (queryAddress) { + return queryAddress.replace(':address', address.trim()) + } + // Fallback: common explorer patterns + if (url.includes('etherscan')) { + return `${url}/address/${address.trim()}` + } + if (url.includes('bscscan')) { + return `${url}/address/${address.trim()}` + } + if (url.includes('tronscan')) { + return `${url}/#/address/${address.trim()}` + } + return `${url}/address/${address.trim()}` + }, [selectedChainConfig, address]) + + const handleOpenExplorer = useCallback(() => { + if (explorerUrl) { + window.open(explorerUrl, '_blank', 'noopener,noreferrer') + } + }, [explorerUrl]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && explorerUrl) { + handleOpenExplorer() + } + }, + [explorerUrl, handleOpenExplorer] + ) + + const evmChains = enabledChains.filter((c) => c.type === 'evm') + const otherChains = enabledChains.filter((c) => c.type !== 'evm') + + return ( +
+ + +
+ {/* Chain Selector */} +
+ + +
+ + {/* Address Input */} +
+ +
+ setAddress(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 font-mono text-sm" + /> + +
+
+ + {/* Info Card */} + + +
+ +
+

+ {t('common:addressLookup.explorerHint')} +

+ {selectedChainConfig?.explorer?.url && ( + + )} +
+
+
+
+ + {/* Quick Links */} + {address.trim() && explorerUrl && ( + + + + + + )} +
+
+ ) +} diff --git a/src/queries/index.ts b/src/queries/index.ts index b8dcf3d0..458077d4 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -58,3 +58,9 @@ export { securityPasswordQueryKeys, type SecurityPasswordQueryResult, } from './use-security-password-query' + +export { + useAddressBalanceQuery, + addressBalanceKeys, + type AddressBalanceResult, +} from './use-address-balance-query' diff --git a/src/queries/use-address-balance-query.ts b/src/queries/use-address-balance-query.ts new file mode 100644 index 00000000..cc87c903 --- /dev/null +++ b/src/queries/use-address-balance-query.ts @@ -0,0 +1,63 @@ +import { useQuery } from '@tanstack/react-query' +import { getAdapterRegistry, setupAdapters } from '@/services/chain-adapter' +import { chainConfigStore, chainConfigSelectors } from '@/stores' +import type { Balance } from '@/services/chain-adapter/types' + +let adaptersInitialized = false +function ensureAdapters() { + if (!adaptersInitialized) { + setupAdapters() + adaptersInitialized = true + } +} + +export const addressBalanceKeys = { + all: ['addressBalance'] as const, + query: (chainId: string, address: string) => ['addressBalance', chainId, address] as const, +} + +export interface AddressBalanceResult { + balance: Balance | null + error: string | null +} + +/** + * Query hook for fetching balance of any address on any chain + */ +export function useAddressBalanceQuery(chainId: string, address: string, enabled = true) { + return useQuery({ + queryKey: addressBalanceKeys.query(chainId, address), + queryFn: async (): Promise => { + if (!chainId || !address) { + return { balance: null, error: 'Missing chain or address' } + } + + try { + ensureAdapters() + + const state = chainConfigStore.state + const chainConfig = chainConfigSelectors.getChainById(state, chainId) + if (!chainConfig) { + return { balance: null, error: `Unknown chain: ${chainId}` } + } + + const registry = getAdapterRegistry() + registry.setChainConfigs([chainConfig]) + + const adapter = registry.getAdapter(chainId) + if (!adapter) { + return { balance: null, error: `No adapter for chain: ${chainId}` } + } + + const balance = await adapter.asset.getNativeBalance(address) + return { balance, error: null } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { balance: null, error: message } + } + }, + enabled: enabled && !!chainId && !!address, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + }) +} diff --git a/src/stackflow/activities/AddressBalanceActivity.tsx b/src/stackflow/activities/AddressBalanceActivity.tsx new file mode 100644 index 00000000..eb6a8744 --- /dev/null +++ b/src/stackflow/activities/AddressBalanceActivity.tsx @@ -0,0 +1,11 @@ +import type { ActivityComponentType } from '@stackflow/react' +import { AppScreen } from '@stackflow/plugin-basic-ui' +import { AddressBalancePage } from '@/pages/address-balance' + +export const AddressBalanceActivity: ActivityComponentType = () => { + return ( + + + + ) +} diff --git a/src/stackflow/activities/AddressTransactionsActivity.tsx b/src/stackflow/activities/AddressTransactionsActivity.tsx new file mode 100644 index 00000000..f6cb53b3 --- /dev/null +++ b/src/stackflow/activities/AddressTransactionsActivity.tsx @@ -0,0 +1,11 @@ +import type { ActivityComponentType } from '@stackflow/react' +import { AppScreen } from '@stackflow/plugin-basic-ui' +import { AddressTransactionsPage } from '@/pages/address-transactions' + +export const AddressTransactionsActivity: ActivityComponentType = () => { + return ( + + + + ) +} diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index a9dec573..9f6dc4f5 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -128,6 +128,16 @@ export function WalletTab() { push("WalletListJob", {}); }, [push]); + // 地址余额查询 + const handleOpenAddressBalance = useCallback(() => { + push("AddressBalanceActivity", {}); + }, [push]); + + // 地址交易查询 + const handleOpenAddressTransactions = useCallback(() => { + push("AddressTransactionsActivity", {}); + }, [push]); + // 交易点击 const handleTransactionClick = useCallback( (tx: TransactionInfo) => { @@ -166,6 +176,8 @@ export function WalletTab() { onOpenSettings={handleOpenWalletSettings} onOpenWalletList={handleOpenWalletList} onAddWallet={handleAddWallet} + onOpenAddressBalance={handleOpenAddressBalance} + onOpenAddressTransactions={handleOpenAddressTransactions} /> {/* 快捷操作按钮 - 颜色跟随主题 */} diff --git a/src/stackflow/stackflow.ts b/src/stackflow/stackflow.ts index cd42dd29..065ec9f4 100644 --- a/src/stackflow/stackflow.ts +++ b/src/stackflow/stackflow.ts @@ -31,6 +31,8 @@ import { SettingsWalletChainsActivity } from './activities/SettingsWalletChainsA import { SettingsStorageActivity } from './activities/SettingsStorageActivity'; import { SettingsSourcesActivity } from './activities/SettingsSourcesActivity'; import { MiniappDetailActivity } from './activities/MiniappDetailActivity'; +import { AddressBalanceActivity } from './activities/AddressBalanceActivity'; +import { AddressTransactionsActivity } from './activities/AddressTransactionsActivity'; import { ChainSelectorJob, WalletRenameJob, @@ -94,6 +96,8 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ WelcomeActivity: '/welcome', SettingsSourcesActivity: '/settings/sources', MiniappDetailActivity: '/miniapp/:appId/detail', + AddressBalanceActivity: '/address-balance', + AddressTransactionsActivity: '/address-transactions', ChainSelectorJob: '/job/chain-selector', WalletRenameJob: '/job/wallet-rename/:walletId', WalletDeleteJob: '/job/wallet-delete/:walletId', @@ -155,6 +159,8 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({ WelcomeActivity, SettingsSourcesActivity, MiniappDetailActivity, + AddressBalanceActivity, + AddressTransactionsActivity, ChainSelectorJob, WalletRenameJob, WalletDeleteJob, From dbf2a985607e640a63fb90970bac95652b3a5869 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 18:25:26 +0800 Subject: [PATCH 02/27] =?UTF-8?q?refactor:=20=E9=85=8D=E7=BD=AE=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E7=9A=84=E9=93=BE=E6=9C=8D=E5=8A=A1=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: 链适配器现在只存储 chainId,通过 chainConfigService 获取配置 主要变更: 1. default-chains.json 添加版本号,结构改为 { version, chains } 2. 修正 EVM/Tron 链的 api.url 为公共 RPC 端点 3. 新增 chainConfigService 作为配置查询单一入口 4. 重构所有 chain-adapter 只存 chainId,运行时动态获取配置 5. 添加配置版本比较和强制合并逻辑 6. IAdapterRegistry 接口改为 registerChain(chainId, type) 配置变更: - ethereum: https://ethereum-rpc.publicnode.com - binance: https://bsc-rpc.publicnode.com - tron: https://api.trongrid.io --- public/configs/default-chains.json | 393 +++++++++--------- src/service-main.ts | 5 +- .../chain-adapter/bioforest/adapter.ts | 27 +- src/services/chain-adapter/bitcoin/adapter.ts | 37 +- src/services/chain-adapter/bitcoin/index.ts | 2 +- src/services/chain-adapter/evm/adapter.ts | 20 +- .../chain-adapter/evm/asset-service.ts | 38 +- .../chain-adapter/evm/chain-service.ts | 36 +- .../chain-adapter/evm/identity-service.ts | 9 +- .../chain-adapter/evm/transaction-service.ts | 95 ++--- src/services/chain-adapter/index.ts | 26 +- src/services/chain-adapter/registry.ts | 32 +- src/services/chain-adapter/tron/adapter.ts | 37 +- .../chain-adapter/tron/asset-service.ts | 94 +++-- src/services/chain-adapter/tron/index.ts | 2 +- src/services/chain-adapter/types.ts | 6 +- src/services/chain-config/index.ts | 65 ++- src/services/chain-config/schema.ts | 12 +- src/services/chain-config/service.ts | 88 ++++ src/services/chain-config/storage.ts | 24 ++ 20 files changed, 601 insertions(+), 447 deletions(-) create mode 100644 src/services/chain-config/service.ts diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 857c68eb..154d192c 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -1,189 +1,206 @@ -[ - { - "id": "bfmeta", - "version": "1.0", - "type": "bioforest", - "name": "BFMeta", - "symbol": "BFM", - "icon": "../icons/bfmeta/chain.svg", - "tokenIconBase": [ - "../icons/bfmeta/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/bfm", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bfm" - ], - "prefix": "b", - "decimals": 8, - "api": { "url": "https://walletapi.bfmeta.info", "path": "bfm" }, - "explorer": { - "url": "https://tracker.bfmeta.org", - "queryTx": "https://tracker.bfmeta.org/#/info/event-details/:signature", - "queryAddress": "https://tracker.bfmeta.org/#/info/address-details/:address", - "queryBlock": "https://tracker.bfmeta.org/#/info/block-details/:height" +{ + "version": "2.0.0", + "chains": [ + { + "id": "bfmeta", + "version": "1.0", + "type": "bioforest", + "name": "BFMeta", + "symbol": "BFM", + "icon": "../icons/bfmeta/chain.svg", + "tokenIconBase": [ + "../icons/bfmeta/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/bfm", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bfm" + ], + "prefix": "b", + "decimals": 8, + "api": { "url": "https://walletapi.bfmeta.info", "path": "bfm" }, + "explorer": { + "url": "https://tracker.bfmeta.org", + "queryTx": "https://tracker.bfmeta.org/#/info/event-details/:signature", + "queryAddress": "https://tracker.bfmeta.org/#/info/address-details/:address", + "queryBlock": "https://tracker.bfmeta.org/#/info/block-details/:height" + } + }, + { + "id": "ccchain", + "version": "1.0", + "type": "bioforest", + "name": "CCChain", + "symbol": "CCC", + "icon": "../icons/ccchain/chain.svg", + "tokenIconBase": [ + "../icons/ccchain/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/ccc", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/ccc" + ], + "prefix": "b", + "decimals": 8, + "api": { "url": "https://walletapi.bfmeta.info", "path": "ccchain" } + }, + { + "id": "pmchain", + "version": "1.0", + "type": "bioforest", + "name": "PMChain", + "symbol": "PMC", + "icon": "../icons/pmchain/chain.svg", + "tokenIconBase": [ + "../icons/pmchain/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/pmc", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/pmc" + ], + "prefix": "b", + "decimals": 8, + "api": { "url": "https://walletapi.bfmeta.info", "path": "pmchain" } + }, + { + "id": "bfchainv2", + "version": "1.0", + "type": "bioforest", + "name": "BFChain V2", + "symbol": "BFT", + "icon": "../icons/bfchainv2/chain.svg", + "tokenIconBase": [ + "../icons/bfchainv2/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/bftv2", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bftv2" + ], + "prefix": "b", + "decimals": 8, + "api": { "url": "https://walletapi.bfmeta.info", "path": "bfchainv2" } + }, + { + "id": "btgmeta", + "version": "1.0", + "type": "bioforest", + "name": "BTGMeta", + "symbol": "BTGM", + "icon": "../icons/btgmeta/chain.svg", + "tokenIconBase": [ + "../icons/btgmeta/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/btgm", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/btgm" + ], + "prefix": "b", + "decimals": 8, + "api": { "url": "https://walletapi.bfmeta.info", "path": "btgmeta" } + }, + { + "id": "biwmeta", + "version": "1.0", + "type": "bioforest", + "name": "BIWMeta", + "symbol": "BIW", + "tokenIconBase": [ + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/biwm", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/biwm" + ], + "prefix": "b", + "decimals": 8, + "api": { "url": "https://walletapi.biw-meta.com", "path": "biwmeta" }, + "explorer": { + "url": "https://tracker.biw-meta.info", + "queryTx": "https://tracker.biw-meta.info/#/info/event-details/:signature", + "queryAddress": "https://tracker.biw-meta.info/#/info/address-details/:address", + "queryBlock": "https://tracker.biw-meta.info/#/info/block-details/:height" + } + }, + { + "id": "ethmeta", + "version": "1.0", + "type": "bioforest", + "name": "ETHMeta", + "symbol": "ETHM", + "icon": "../icons/ethmeta/chain.svg", + "tokenIconBase": [ + "../icons/ethmeta/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/ethm", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/ethm" + ], + "prefix": "b", + "decimals": 8, + "api": { "url": "https://walletapi.bfmeta.info", "path": "ethmeta" } + }, + { + "id": "ethereum", + "version": "1.0", + "type": "evm", + "name": "Ethereum", + "symbol": "ETH", + "icon": "../icons/ethereum/chain.svg", + "tokenIconBase": [ + "../icons/ethereum/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/eth", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/eth" + ], + "decimals": 18, + "api": { "url": "https://ethereum-rpc.publicnode.com" }, + "explorer": { + "url": "https://etherscan.io", + "queryTx": "https://etherscan.io/tx/:hash", + "queryAddress": "https://etherscan.io/address/:address" + } + }, + { + "id": "binance", + "version": "1.0", + "type": "evm", + "name": "BNB Smart Chain", + "symbol": "BNB", + "icon": "../icons/binance/chain.svg", + "tokenIconBase": [ + "../icons/binance/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/bsc", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bsc" + ], + "decimals": 18, + "api": { "url": "https://bsc-rpc.publicnode.com" }, + "explorer": { + "url": "https://bscscan.com", + "queryTx": "https://bscscan.com/tx/:hash", + "queryAddress": "https://bscscan.com/address/:address" + } + }, + { + "id": "tron", + "version": "1.0", + "type": "tron", + "name": "Tron", + "symbol": "TRX", + "icon": "../icons/tron/chain.svg", + "tokenIconBase": [ + "../icons/tron/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/tron", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/tron" + ], + "decimals": 6, + "api": { "url": "https://api.trongrid.io" }, + "explorer": { + "url": "https://tronscan.org", + "queryTx": "https://tronscan.org/#/transaction/:hash", + "queryAddress": "https://tronscan.org/#/address/:address" + } + }, + { + "id": "bitcoin", + "version": "1.0", + "type": "bip39", + "name": "Bitcoin", + "symbol": "BTC", + "icon": "../icons/bitcoin/chain.svg", + "tokenIconBase": [ + "../icons/bitcoin/tokens", + "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/btcm", + "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/btcm" + ], + "decimals": 8, + "explorer": { + "url": "https://mempool.space", + "queryTx": "https://mempool.space/tx/:hash", + "queryAddress": "https://mempool.space/address/:address" + } } - }, - { - "id": "ccchain", - "version": "1.0", - "type": "bioforest", - "name": "CCChain", - "symbol": "CCC", - "icon": "../icons/ccchain/chain.svg", - "tokenIconBase": [ - "../icons/ccchain/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/ccc", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/ccc" - ], - "prefix": "b", - "decimals": 8, - "api": { "url": "https://walletapi.bfmeta.info", "path": "ccchain" } - }, - { - "id": "pmchain", - "version": "1.0", - "type": "bioforest", - "name": "PMChain", - "symbol": "PMC", - "icon": "../icons/pmchain/chain.svg", - "tokenIconBase": [ - "../icons/pmchain/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/pmc", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/pmc" - ], - "prefix": "b", - "decimals": 8, - "api": { "url": "https://walletapi.bfmeta.info", "path": "pmchain" } - }, - { - "id": "bfchainv2", - "version": "1.0", - "type": "bioforest", - "name": "BFChain V2", - "symbol": "BFT", - "icon": "../icons/bfchainv2/chain.svg", - "tokenIconBase": [ - "../icons/bfchainv2/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/bftv2", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bftv2" - ], - "prefix": "b", - "decimals": 8, - "api": { "url": "https://walletapi.bfmeta.info", "path": "bfchainv2" } - }, - { - "id": "btgmeta", - "version": "1.0", - "type": "bioforest", - "name": "BTGMeta", - "symbol": "BTGM", - "icon": "../icons/btgmeta/chain.svg", - "tokenIconBase": [ - "../icons/btgmeta/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/btgm", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/btgm" - ], - "prefix": "b", - "decimals": 8, - "api": { "url": "https://walletapi.bfmeta.info", "path": "btgmeta" } - }, - - { - "id": "biwmeta", - "version": "1.0", - "type": "bioforest", - "name": "BIWMeta", - "symbol": "BIW", - "tokenIconBase": [ - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/biwm", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/biwm" - ], - "prefix": "b", - "decimals": 8, - "api": { "url": "https://walletapi.biw-meta.com", "path": "biwmeta" }, - "explorer": { - "url": "https://tracker.biw-meta.info", - "queryTx": "https://tracker.biw-meta.info/#/info/event-details/:signature", - "queryAddress": "https://tracker.biw-meta.info/#/info/address-details/:address", - "queryBlock": "https://tracker.biw-meta.info/#/info/block-details/:height" - } - }, - { - "id": "ethmeta", - "version": "1.0", - "type": "bioforest", - "name": "ETHMeta", - "symbol": "ETHM", - "icon": "../icons/ethmeta/chain.svg", - "tokenIconBase": [ - "../icons/ethmeta/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/ethm", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/ethm" - ], - "prefix": "b", - "decimals": 8, - "api": { "url": "https://walletapi.bfmeta.info", "path": "ethmeta" } - }, - - { - "id": "ethereum", - "version": "1.0", - "type": "evm", - "name": "Ethereum", - "symbol": "ETH", - "icon": "../icons/ethereum/chain.svg", - "tokenIconBase": [ - "../icons/ethereum/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/eth", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/eth" - ], - "decimals": 18, - "api": { "url": "https://walletapi.bfmeta.info", "path": "eth" }, - "explorer": { "url": "https://etherscan.io" } - }, - { - "id": "binance", - "version": "1.0", - "type": "evm", - "name": "BNB Smart Chain", - "symbol": "BNB", - "icon": "../icons/binance/chain.svg", - "tokenIconBase": [ - "../icons/binance/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/bsc", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/bsc" - ], - "decimals": 18, - "api": { "url": "https://walletapi.bfmeta.info", "path": "bnb" }, - "explorer": { "url": "https://bscscan.com" } - }, - { - "id": "tron", - "version": "1.0", - "type": "tron", - "name": "Tron", - "symbol": "TRX", - "icon": "../icons/tron/chain.svg", - "tokenIconBase": [ - "../icons/tron/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/tron", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/tron" - ], - "decimals": 6, - "api": { "url": "https://walletapi.bfmeta.info", "path": "tron" }, - "explorer": { "url": "https://tronscan.org" } - }, - { - "id": "bitcoin", - "version": "1.0", - "type": "bip39", - "name": "Bitcoin", - "symbol": "BTC", - "icon": "../icons/bitcoin/chain.svg", - "tokenIconBase": [ - "../icons/bitcoin/tokens", - "https://bfm-fonts-cdn.oss-cn-hongkong.aliyuncs.com/meta-icon/btcm", - "https://raw.githubusercontent.com/BFChainMeta/fonts-cdn/main/src/meta-icon/btcm" - ], - "decimals": 8, - "explorer": { "url": "https://mempool.space" } - } -] + ] +} diff --git a/src/service-main.ts b/src/service-main.ts index 0e5092e1..474d4fe7 100644 --- a/src/service-main.ts +++ b/src/service-main.ts @@ -3,7 +3,7 @@ import { installLegacyAuthorizeHashRewriter, rewriteLegacyAuthorizeHashInPlace, } from '@/services/authorize/deep-link' -import { setupAdapters, getAdapterRegistry } from '@/services/chain-adapter' +import { setupAdapters, registerChainConfigs } from '@/services/chain-adapter' import { getEnabledChains } from '@/services/chain-config' export type ServiceMainCleanup = () => void @@ -32,8 +32,7 @@ export function startServiceMain(): ServiceMainCleanup { const snapshot = chainConfigStore.state.snapshot if (snapshot) { const enabledConfigs = getEnabledChains(snapshot) - const registry = getAdapterRegistry() - registry.setChainConfigs(enabledConfigs) + registerChainConfigs(enabledConfigs) } }) diff --git a/src/services/chain-adapter/bioforest/adapter.ts b/src/services/chain-adapter/bioforest/adapter.ts index 3f25ece4..3f003f84 100644 --- a/src/services/chain-adapter/bioforest/adapter.ts +++ b/src/services/chain-adapter/bioforest/adapter.ts @@ -2,7 +2,7 @@ * BioForest Chain Adapter */ -import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { ChainConfigType } from '@/services/chain-config' import type { IChainAdapter, IStakingService } from '../types' import { BioforestIdentityService } from './identity-service' import { BioforestAssetService } from './asset-service' @@ -17,33 +17,28 @@ export class BioforestAdapter implements IChainAdapter { readonly asset: BioforestAssetService readonly transaction: BioforestTransactionService readonly chain: BioforestChainService - readonly staking: IStakingService | null = null // TODO: Implement staking + readonly staking: IStakingService | null = null private initialized = false - constructor(config: ChainConfig) { - this.chainId = config.id - this.identity = new BioforestIdentityService(config) - this.asset = new BioforestAssetService(config) - this.transaction = new BioforestTransactionService(config) - this.chain = new BioforestChainService(config) + constructor(chainId: string) { + this.chainId = chainId + this.identity = new BioforestIdentityService(chainId) + this.asset = new BioforestAssetService(chainId) + this.transaction = new BioforestTransactionService(chainId) + this.chain = new BioforestChainService(chainId) } - async initialize(_config: ChainConfig): Promise { + async initialize(): Promise { if (this.initialized) return - - // Perform any async initialization - // e.g., load chain-specific SDK, verify connection - this.initialized = true } dispose(): void { - // Clean up resources this.initialized = false } } -export function createBioforestAdapter(config: ChainConfig): IChainAdapter { - return new BioforestAdapter(config) +export function createBioforestAdapter(chainId: string): IChainAdapter { + return new BioforestAdapter(chainId) } diff --git a/src/services/chain-adapter/bitcoin/adapter.ts b/src/services/chain-adapter/bitcoin/adapter.ts index aadb0e97..f3f64437 100644 --- a/src/services/chain-adapter/bitcoin/adapter.ts +++ b/src/services/chain-adapter/bitcoin/adapter.ts @@ -1,10 +1,10 @@ /** * Bitcoin Chain Adapter - * + * * Full adapter for Bitcoin network using mempool.space API */ -import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { ChainConfigType } from '@/services/chain-config' import type { IChainAdapter, IStakingService } from '../types' import { BitcoinIdentityService } from './identity-service' import { BitcoinAssetService } from './asset-service' @@ -12,34 +12,27 @@ import { BitcoinChainService } from './chain-service' import { BitcoinTransactionService } from './transaction-service' export class BitcoinAdapter implements IChainAdapter { - readonly config: ChainConfig + readonly chainId: string + readonly chainType: ChainConfigType = 'bip39' readonly identity: BitcoinIdentityService readonly asset: BitcoinAssetService readonly chain: BitcoinChainService readonly transaction: BitcoinTransactionService readonly staking: IStakingService | null = null - constructor(config: ChainConfig) { - this.config = config - this.identity = new BitcoinIdentityService(config) - this.asset = new BitcoinAssetService(config) - this.chain = new BitcoinChainService(config) - this.transaction = new BitcoinTransactionService(config) + constructor(chainId: string) { + this.chainId = chainId + this.identity = new BitcoinIdentityService(chainId) + this.asset = new BitcoinAssetService(chainId) + this.chain = new BitcoinChainService(chainId) + this.transaction = new BitcoinTransactionService(chainId) } - get chainId(): string { - return this.config.id - } - - get chainType(): ChainConfigType { - return 'bip39' - } + async initialize(): Promise {} - async initialize(_config: ChainConfig): Promise { - // No async initialization needed - } + dispose(): void {} +} - dispose(): void { - // No cleanup needed - } +export function createBitcoinAdapter(chainId: string): IChainAdapter { + return new BitcoinAdapter(chainId) } diff --git a/src/services/chain-adapter/bitcoin/index.ts b/src/services/chain-adapter/bitcoin/index.ts index 14866cfb..f0d148a6 100644 --- a/src/services/chain-adapter/bitcoin/index.ts +++ b/src/services/chain-adapter/bitcoin/index.ts @@ -2,7 +2,7 @@ * Bitcoin Chain Adapter exports */ -export { BitcoinAdapter } from './adapter' +export { BitcoinAdapter, createBitcoinAdapter } from './adapter' export { BitcoinIdentityService } from './identity-service' export { BitcoinAssetService } from './asset-service' export { BitcoinChainService } from './chain-service' diff --git a/src/services/chain-adapter/evm/adapter.ts b/src/services/chain-adapter/evm/adapter.ts index 8b263620..bc071621 100644 --- a/src/services/chain-adapter/evm/adapter.ts +++ b/src/services/chain-adapter/evm/adapter.ts @@ -2,7 +2,7 @@ * EVM Chain Adapter */ -import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { ChainConfigType } from '@/services/chain-config' import type { IChainAdapter, IStakingService } from '../types' import { EvmIdentityService } from './identity-service' import { EvmAssetService } from './asset-service' @@ -21,15 +21,15 @@ export class EvmAdapter implements IChainAdapter { private initialized = false - constructor(config: ChainConfig) { - this.chainId = config.id - this.identity = new EvmIdentityService(config) - this.asset = new EvmAssetService(config) - this.transaction = new EvmTransactionService(config) - this.chain = new EvmChainService(config) + constructor(chainId: string) { + this.chainId = chainId + this.identity = new EvmIdentityService(chainId) + this.asset = new EvmAssetService(chainId) + this.transaction = new EvmTransactionService(chainId) + this.chain = new EvmChainService(chainId) } - async initialize(_config: ChainConfig): Promise { + async initialize(): Promise { if (this.initialized) return this.initialized = true } @@ -39,6 +39,6 @@ export class EvmAdapter implements IChainAdapter { } } -export function createEvmAdapter(config: ChainConfig): IChainAdapter { - return new EvmAdapter(config) +export function createEvmAdapter(chainId: string): IChainAdapter { + return new EvmAdapter(chainId) } diff --git a/src/services/chain-adapter/evm/asset-service.ts b/src/services/chain-adapter/evm/asset-service.ts index 05e69b37..c92f8859 100644 --- a/src/services/chain-adapter/evm/asset-service.ts +++ b/src/services/chain-adapter/evm/asset-service.ts @@ -4,17 +4,11 @@ * Provides balance queries for EVM chains via public JSON-RPC endpoints. */ -import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config/service' import type { IAssetService, Address, Balance, TokenMetadata } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' -/** Default public RPC endpoints for EVM chains */ -const DEFAULT_RPC_URLS: Record = { - ethereum: 'https://ethereum-rpc.publicnode.com', - binance: 'https://bsc-rpc.publicnode.com', -} - interface JsonRpcResponse { jsonrpc: string id: number @@ -23,12 +17,22 @@ interface JsonRpcResponse { } export class EvmAssetService implements IAssetService { - private readonly config: ChainConfig - private readonly rpcUrl: string + private readonly chainId: string + + constructor(chainId: string) { + this.chainId = chainId + } + + private get rpcUrl(): string { + return chainConfigService.getRpcUrl(this.chainId) + } + + private get decimals(): number { + return chainConfigService.getDecimals(this.chainId) + } - constructor(config: ChainConfig) { - this.config = config - this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['ethereum']! + private get symbol(): string { + return chainConfigService.getSymbol(this.chainId) } private async rpc(method: string, params: unknown[]): Promise { @@ -63,13 +67,13 @@ export class EvmAssetService implements IAssetService { const hexBalance = await this.rpc('eth_getBalance', [address, 'latest']) const balance = BigInt(hexBalance).toString() return { - amount: Amount.fromRaw(balance, this.config.decimals, this.config.symbol), - symbol: this.config.symbol, + amount: Amount.fromRaw(balance, this.decimals, this.symbol), + symbol: this.symbol, } } catch { return { - amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), - symbol: this.config.symbol, + amount: Amount.fromRaw('0', this.decimals, this.symbol), + symbol: this.symbol, } } } @@ -98,8 +102,6 @@ export class EvmAssetService implements IAssetService { } async getTokenMetadata(tokenAddress: Address): Promise { - // ERC20 metadata queries would require multiple eth_call requests - // Return minimal info for now return { address: tokenAddress, name: 'Unknown Token', diff --git a/src/services/chain-adapter/evm/chain-service.ts b/src/services/chain-adapter/evm/chain-service.ts index 4505c127..e6c139f2 100644 --- a/src/services/chain-adapter/evm/chain-service.ts +++ b/src/services/chain-adapter/evm/chain-service.ts @@ -4,17 +4,11 @@ * Provides chain info and gas price queries via public JSON-RPC endpoints. */ -import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config/service' import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' -/** Default public RPC endpoints for EVM chains */ -const DEFAULT_RPC_URLS: Record = { - ethereum: 'https://ethereum-rpc.publicnode.com', - binance: 'https://bsc-rpc.publicnode.com', -} - interface JsonRpcResponse { jsonrpc: string id: number @@ -23,12 +17,14 @@ interface JsonRpcResponse { } export class EvmChainService implements IChainService { - private readonly config: ChainConfig - private readonly rpcUrl: string + private readonly chainId: string + + constructor(chainId: string) { + this.chainId = chainId + } - constructor(config: ChainConfig) { - this.config = config - this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['ethereum']! + private get rpcUrl(): string { + return chainConfigService.getRpcUrl(this.chainId) } private async rpc(method: string, params: unknown[] = []): Promise { @@ -59,14 +55,15 @@ export class EvmChainService implements IChainService { } getChainInfo(): ChainInfo { + const config = chainConfigService.getConfig(this.chainId) return { - chainId: this.config.id, - name: this.config.name, - symbol: this.config.symbol, - decimals: this.config.decimals, - blockTime: 12, // ~12 seconds for Ethereum + chainId: this.chainId, + name: config?.name ?? this.chainId, + symbol: config?.symbol ?? '', + decimals: config?.decimals ?? 18, + blockTime: 12, confirmations: 12, - explorerUrl: this.config.explorer?.url, + explorerUrl: config?.explorer?.url, } } @@ -91,8 +88,7 @@ export class EvmChainService implements IChainService { lastUpdated: Date.now(), } } catch { - // Return default gas prices - const defaultGas = Amount.fromRaw('20000000000', 9, 'Gwei') // 20 Gwei + const defaultGas = Amount.fromRaw('20000000000', 9, 'Gwei') return { slow: defaultGas, standard: defaultGas, diff --git a/src/services/chain-adapter/evm/identity-service.ts b/src/services/chain-adapter/evm/identity-service.ts index c76128e3..b3083b4a 100644 --- a/src/services/chain-adapter/evm/identity-service.ts +++ b/src/services/chain-adapter/evm/identity-service.ts @@ -2,12 +2,15 @@ * EVM Identity Service */ -import type { ChainConfig } from '@/services/chain-config' import type { IIdentityService, Address, Signature } from '../types' import { toChecksumAddress, isValidAddress } from '@/lib/crypto' export class EvmIdentityService implements IIdentityService { - constructor(_config: ChainConfig) {} + private readonly chainId: string + + constructor(chainId: string) { + this.chainId = chainId + } async deriveAddress(_seed: Uint8Array, _index = 0): Promise
{ throw new Error('Use deriveAddressesForChains from @/lib/crypto instead') @@ -26,7 +29,6 @@ export class EvmIdentityService implements IIdentityService { } async signMessage(_message: string | Uint8Array, _privateKey: Uint8Array): Promise { - // TODO: Implement EIP-191 personal_sign throw new Error('Not implemented') } @@ -35,7 +37,6 @@ export class EvmIdentityService implements IIdentityService { _signature: Signature, _address: Address, ): Promise { - // TODO: Implement signature verification throw new Error('Not implemented') } } diff --git a/src/services/chain-adapter/evm/transaction-service.ts b/src/services/chain-adapter/evm/transaction-service.ts index 9485cb79..bf7d2789 100644 --- a/src/services/chain-adapter/evm/transaction-service.ts +++ b/src/services/chain-adapter/evm/transaction-service.ts @@ -1,11 +1,11 @@ /** * EVM Transaction Service - * + * * Handles transaction building, signing, and broadcasting for EVM chains. * Uses standard Ethereum JSON-RPC API (compatible with PublicNode, Infura, etc.) */ -import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config/service' import type { ITransactionService, TransferParams, @@ -24,32 +24,33 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' /** EVM Chain IDs */ const EVM_CHAIN_IDS: Record = { - 'ethereum': 1, + ethereum: 1, 'ethereum-sepolia': 11155111, - 'binance': 56, + binance: 56, 'bsc-testnet': 97, } -/** Default RPC endpoints (PublicNode - free, no API key required) */ -const DEFAULT_RPC_URLS: Record = { - 'ethereum': 'https://ethereum-rpc.publicnode.com', - 'ethereum-sepolia': 'https://ethereum-sepolia-rpc.publicnode.com', - 'binance': 'https://bsc-rpc.publicnode.com', - 'bsc-testnet': 'https://bsc-testnet-rpc.publicnode.com', -} - export class EvmTransactionService implements ITransactionService { - private readonly config: ChainConfig - private readonly rpcUrl: string + private readonly chainId: string private readonly evmChainId: number - constructor(config: ChainConfig) { - this.config = config - this.rpcUrl = config.api?.url ?? DEFAULT_RPC_URLS[config.id] ?? 'https://ethereum-rpc.publicnode.com' - this.evmChainId = EVM_CHAIN_IDS[config.id] ?? 1 + constructor(chainId: string) { + this.chainId = chainId + this.evmChainId = EVM_CHAIN_IDS[chainId] ?? 1 + } + + private get rpcUrl(): string { + return chainConfigService.getRpcUrl(this.chainId) + } + + private get decimals(): number { + return chainConfigService.getDecimals(this.chainId) + } + + private get symbol(): string { + return chainConfigService.getSymbol(this.chainId) } - /** Make a JSON-RPC call */ private async rpc(method: string, params: unknown[] = []): Promise { const response = await fetch(this.rpcUrl, { method: 'POST', @@ -69,31 +70,26 @@ export class EvmTransactionService implements ITransactionService { ) } - const json = await response.json() as { result?: T; error?: { code: number; message: string } } + const json = (await response.json()) as { result?: T; error?: { code: number; message: string } } if (json.error) { - throw new ChainServiceError( - ChainErrorCodes.NETWORK_ERROR, - json.error.message, - { code: json.error.code }, - ) + throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, json.error.message, { + code: json.error.code, + }) } return json.result as T } async estimateFee(_params: TransferParams): Promise { - // Get current gas price from network const gasPriceHex = await this.rpc('eth_gasPrice') const gasPrice = BigInt(gasPriceHex) - - // Estimate gas (21000 for simple ETH transfer) + const gasLimit = 21000n const baseFee = gasLimit * gasPrice - // Calculate slow/standard/fast with multipliers - const slow = Amount.fromRaw((baseFee * 80n / 100n).toString(), this.config.decimals, this.config.symbol) - const standard = Amount.fromRaw(baseFee.toString(), this.config.decimals, this.config.symbol) - const fast = Amount.fromRaw((baseFee * 120n / 100n).toString(), this.config.decimals, this.config.symbol) + const slow = Amount.fromRaw(((baseFee * 80n) / 100n).toString(), this.decimals, this.symbol) + const standard = Amount.fromRaw(baseFee.toString(), this.decimals, this.symbol) + const fast = Amount.fromRaw(((baseFee * 120n) / 100n).toString(), this.decimals, this.symbol) return { slow: { amount: slow, estimatedTime: 60 }, @@ -103,19 +99,17 @@ export class EvmTransactionService implements ITransactionService { } async buildTransaction(params: TransferParams): Promise { - // Get nonce for the sender const nonceHex = await this.rpc('eth_getTransactionCount', [params.from, 'pending']) const nonce = parseInt(nonceHex, 16) - // Get current gas price const gasPriceHex = await this.rpc('eth_gasPrice') return { - chainId: this.config.id, + chainId: this.chainId, data: { nonce, gasPrice: gasPriceHex, - gasLimit: '0x5208', // 21000 in hex + gasLimit: '0x5208', to: params.to, value: '0x' + params.amount.raw.toString(16), data: '0x', @@ -138,7 +132,6 @@ export class EvmTransactionService implements ITransactionService { chainId: number } - // RLP encode the transaction for signing (EIP-155) const rawTx = this.rlpEncode([ this.toRlpHex(txData.nonce), txData.gasPrice, @@ -151,23 +144,18 @@ export class EvmTransactionService implements ITransactionService { '0x', ]) - // Hash and sign (use recovered format to get recovery bit) const msgHash = keccak_256(hexToBytes(rawTx.slice(2))) const sigBytes = secp256k1.sign(msgHash, privateKey, { prehash: false, format: 'recovered' }) - - // Parse signature: recovered format is 65 bytes (r[32] + s[32] + recovery[1]) + const r = BigInt('0x' + bytesToHex(sigBytes.slice(0, 32))) const s = BigInt('0x' + bytesToHex(sigBytes.slice(32, 64))) const recovery = sigBytes[64]! - - // Calculate v value (EIP-155) + const v = txData.chainId * 2 + 35 + recovery - // Get r and s as hex strings const rHex = r.toString(16).padStart(64, '0') const sHex = s.toString(16).padStart(64, '0') - // RLP encode signed transaction const signedRaw = this.rlpEncode([ this.toRlpHex(txData.nonce), txData.gasPrice, @@ -181,7 +169,7 @@ export class EvmTransactionService implements ITransactionService { ]) return { - chainId: this.config.id, + chainId: this.chainId, data: signedRaw, signature: '0x' + rHex + sHex, } @@ -193,15 +181,13 @@ export class EvmTransactionService implements ITransactionService { return txHash } - /** Convert number to RLP hex format */ private toRlpHex(n: number): string { if (n === 0) return '0x' return '0x' + n.toString(16) } - /** Simple RLP encoding for transaction */ private rlpEncode(items: string[]): string { - const encoded = items.map(item => { + const encoded = items.map((item) => { if (item === '0x' || item === '') { return new Uint8Array([0x80]) } @@ -285,7 +271,7 @@ export class EvmTransactionService implements ITransactionService { if (!tx) return null - const block = tx.blockNumber + const block = tx.blockNumber ? await this.rpc<{ timestamp: string }>('eth_getBlockByNumber', [tx.blockNumber, false]) : null @@ -293,14 +279,14 @@ export class EvmTransactionService implements ITransactionService { hash: tx.hash, from: tx.from, to: tx.to ?? '', - amount: Amount.fromRaw(BigInt(tx.value).toString(), this.config.decimals, this.config.symbol), + amount: Amount.fromRaw(BigInt(tx.value).toString(), this.decimals, this.symbol), fee: receipt ? Amount.fromRaw( (BigInt(receipt.gasUsed) * BigInt(tx.gasPrice)).toString(), - this.config.decimals, - this.config.symbol, + this.decimals, + this.symbol, ) - : Amount.fromRaw('0', this.config.decimals, this.config.symbol), + : Amount.fromRaw('0', this.decimals, this.symbol), status: { status: receipt?.status === '0x1' ? 'confirmed' : receipt ? 'failed' : 'pending', confirmations: receipt ? 12 : 0, @@ -316,9 +302,6 @@ export class EvmTransactionService implements ITransactionService { } async getTransactionHistory(_address: string, _limit = 20): Promise { - // Note: Standard JSON-RPC doesn't support transaction history queries - // This would require an indexer service like Etherscan API - // For now, return empty array - can be extended with Etherscan/BlockScout API return [] } } diff --git a/src/services/chain-adapter/index.ts b/src/services/chain-adapter/index.ts index 319c0ac1..75634bf9 100644 --- a/src/services/chain-adapter/index.ts +++ b/src/services/chain-adapter/index.ts @@ -42,29 +42,29 @@ export { getAdapterRegistry, resetAdapterRegistry } from './registry' export { BioforestAdapter, createBioforestAdapter } from './bioforest' export { EvmAdapter, createEvmAdapter } from './evm' export { Bip39Adapter, createBip39Adapter } from './bip39' -export { TronAdapter } from './tron' -export { BitcoinAdapter } from './bitcoin' +export { TronAdapter, createTronAdapter } from './tron' +export { BitcoinAdapter, createBitcoinAdapter } from './bitcoin' // Setup function to register all adapters import { getAdapterRegistry } from './registry' import { createBioforestAdapter } from './bioforest' import { createEvmAdapter } from './evm' -import { TronAdapter } from './tron' -import { BitcoinAdapter } from './bitcoin' +import { createTronAdapter } from './tron' +import { createBitcoinAdapter } from './bitcoin' import type { ChainConfig } from '@/services/chain-config' -function createTronAdapter(config: ChainConfig) { - return new TronAdapter(config) -} - -function createBitcoinAdapter(config: ChainConfig) { - return new BitcoinAdapter(config) -} - export function setupAdapters(): void { const registry = getAdapterRegistry() registry.register('bioforest', createBioforestAdapter) registry.register('evm', createEvmAdapter) registry.register('tron', createTronAdapter) - registry.register('bip39', createBitcoinAdapter) // Bitcoin uses bip39 type + registry.register('bip39', createBitcoinAdapter) +} + +/** Register chain configs with the adapter registry */ +export function registerChainConfigs(configs: ChainConfig[]): void { + const registry = getAdapterRegistry() + for (const config of configs) { + registry.registerChain(config.id, config.type) + } } diff --git a/src/services/chain-adapter/registry.ts b/src/services/chain-adapter/registry.ts index 0aca7807..b1176840 100644 --- a/src/services/chain-adapter/registry.ts +++ b/src/services/chain-adapter/registry.ts @@ -2,46 +2,39 @@ * 链适配器注册表 */ -import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { ChainConfigType } from '@/services/chain-config' import type { IChainAdapter, IAdapterRegistry, AdapterFactory } from './types' import { ChainServiceError, ChainErrorCodes } from './types' class AdapterRegistry implements IAdapterRegistry { private factories = new Map() private adapters = new Map() - private configs = new Map() + private chainTypes = new Map() register(type: ChainConfigType, factory: AdapterFactory): void { this.factories.set(type, factory) } - setChainConfigs(configs: ChainConfig[]): void { - this.configs.clear() - for (const config of configs) { - this.configs.set(config.id, config) - } + registerChain(chainId: string, type: ChainConfigType): void { + this.chainTypes.set(chainId, type) } getAdapter(chainId: string): IChainAdapter | null { - // Return cached adapter const cached = this.adapters.get(chainId) if (cached) return cached - // Get config - const config = this.configs.get(chainId) - if (!config) return null + const type = this.chainTypes.get(chainId) + if (!type) return null - // Get factory - const factory = this.factories.get(config.type) + const factory = this.factories.get(type) if (!factory) { throw new ChainServiceError( ChainErrorCodes.CHAIN_NOT_SUPPORTED, - `No adapter factory registered for chain type: ${config.type}`, + `No adapter factory registered for chain type: ${type}`, ) } - // Create and cache adapter - const adapter = factory(config) + const adapter = factory(chainId) this.adapters.set(chainId, adapter) return adapter } @@ -49,10 +42,10 @@ class AdapterRegistry implements IAdapterRegistry { hasAdapter(chainId: string): boolean { if (this.adapters.has(chainId)) return true - const config = this.configs.get(chainId) - if (!config) return false + const type = this.chainTypes.get(chainId) + if (!type) return false - return this.factories.has(config.type) + return this.factories.has(type) } listAdapters(): string[] { @@ -67,7 +60,6 @@ class AdapterRegistry implements IAdapterRegistry { } } -// Singleton instance let registryInstance: AdapterRegistry | null = null export function getAdapterRegistry(): IAdapterRegistry { diff --git a/src/services/chain-adapter/tron/adapter.ts b/src/services/chain-adapter/tron/adapter.ts index 8575fec0..4de532d8 100644 --- a/src/services/chain-adapter/tron/adapter.ts +++ b/src/services/chain-adapter/tron/adapter.ts @@ -1,10 +1,10 @@ /** * Tron Chain Adapter - * + * * Full adapter for Tron network using PublicNode HTTP API */ -import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import type { ChainConfigType } from '@/services/chain-config' import type { IChainAdapter, IStakingService } from '../types' import { TronIdentityService } from './identity-service' import { TronAssetService } from './asset-service' @@ -12,34 +12,27 @@ import { TronChainService } from './chain-service' import { TronTransactionService } from './transaction-service' export class TronAdapter implements IChainAdapter { - readonly config: ChainConfig + readonly chainId: string + readonly chainType: ChainConfigType = 'tron' readonly identity: TronIdentityService readonly asset: TronAssetService readonly chain: TronChainService readonly transaction: TronTransactionService readonly staking: IStakingService | null = null - constructor(config: ChainConfig) { - this.config = config - this.identity = new TronIdentityService(config) - this.asset = new TronAssetService(config) - this.chain = new TronChainService(config) - this.transaction = new TronTransactionService(config) + constructor(chainId: string) { + this.chainId = chainId + this.identity = new TronIdentityService(chainId) + this.asset = new TronAssetService(chainId) + this.chain = new TronChainService(chainId) + this.transaction = new TronTransactionService(chainId) } - get chainId(): string { - return this.config.id - } - - get chainType(): ChainConfigType { - return 'tron' - } + async initialize(): Promise {} - async initialize(_config: ChainConfig): Promise { - // No async initialization needed - } + dispose(): void {} +} - dispose(): void { - // No cleanup needed - } +export function createTronAdapter(chainId: string): IChainAdapter { + return new TronAdapter(chainId) } diff --git a/src/services/chain-adapter/tron/asset-service.ts b/src/services/chain-adapter/tron/asset-service.ts index 10380398..aae2dd5f 100644 --- a/src/services/chain-adapter/tron/asset-service.ts +++ b/src/services/chain-adapter/tron/asset-service.ts @@ -2,26 +2,29 @@ * Tron Asset Service */ -import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config/service' import type { IAssetService, Balance, TokenMetadata, Address } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' import type { TronAccount } from './types' -/** Default Tron RPC endpoints */ -const DEFAULT_RPC_URLS: Record = { - 'tron': 'https://tron-rpc.publicnode.com', - 'tron-nile': 'https://nile.trongrid.io', - 'tron-shasta': 'https://api.shasta.trongrid.io', -} - export class TronAssetService implements IAssetService { - private readonly config: ChainConfig - private readonly rpcUrl: string + private readonly chainId: string + + constructor(chainId: string) { + this.chainId = chainId + } + + private get rpcUrl(): string { + return chainConfigService.getRpcUrl(this.chainId) + } - constructor(config: ChainConfig) { - this.config = config - this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['tron']! + private get decimals(): number { + return chainConfigService.getDecimals(this.chainId) + } + + private get symbol(): string { + return chainConfigService.getSymbol(this.chainId) } private async api(endpoint: string, body?: unknown): Promise { @@ -32,10 +35,7 @@ export class TronAssetService implements IAssetService { const response = await fetch(url, init) if (!response.ok) { - throw new ChainServiceError( - ChainErrorCodes.NETWORK_ERROR, - `Tron API error: ${response.status}`, - ) + throw new ChainServiceError(ChainErrorCodes.NETWORK_ERROR, `Tron API error: ${response.status}`) } return response.json() as Promise @@ -48,45 +48,65 @@ export class TronAssetService implements IAssetService { visible: true, }) - // Empty object means account doesn't exist yet (0 balance) if (!account || !('balance' in account)) { return { - amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), - symbol: this.config.symbol, + amount: Amount.fromRaw('0', this.decimals, this.symbol), + symbol: this.symbol, } } return { - amount: Amount.fromRaw(account.balance.toString(), this.config.decimals, this.config.symbol), - symbol: this.config.symbol, + amount: Amount.fromRaw(account.balance.toString(), this.decimals, this.symbol), + symbol: this.symbol, } } catch { return { - amount: Amount.fromRaw('0', this.config.decimals, this.config.symbol), - symbol: this.config.symbol, + amount: Amount.fromRaw('0', this.decimals, this.symbol), + symbol: this.symbol, } } } - async getTokenBalance(_address: Address, _tokenAddress: Address): Promise { - // TRC20 token balance requires contract calls - not implemented - return { - amount: Amount.fromRaw('0', 18, 'TOKEN'), - symbol: 'TOKEN', + async getTokenBalance(address: Address, tokenAddress: Address): Promise { + try { + const result = await this.api<{ trc20: Array> } | undefined>( + '/wallet/getaccount', + { address, visible: true }, + ) + + if (result?.trc20) { + for (const token of result.trc20) { + if (token[tokenAddress]) { + return { + amount: Amount.fromRaw(token[tokenAddress]!, 18, 'TOKEN'), + symbol: 'TOKEN', + } + } + } + } + + return { + amount: Amount.fromRaw('0', 18, 'TOKEN'), + symbol: 'TOKEN', + } + } catch { + return { + amount: Amount.fromRaw('0', 18, 'TOKEN'), + symbol: 'TOKEN', + } } } - async getTokenBalances(_address: Address): Promise { - // Would require TronGrid API for full token list - return [] + async getTokenBalances(address: Address): Promise { + const nativeBalance = await this.getNativeBalance(address) + return [nativeBalance] } - async getTokenMetadata(_tokenAddress: Address): Promise { - // TRC20 token metadata requires contract calls + async getTokenMetadata(tokenAddress: Address): Promise { return { - address: _tokenAddress, - name: 'Unknown', - symbol: 'UNKNOWN', + address: tokenAddress, + name: 'TRC20 Token', + symbol: 'TOKEN', decimals: 18, } } diff --git a/src/services/chain-adapter/tron/index.ts b/src/services/chain-adapter/tron/index.ts index 53673858..4dc60b65 100644 --- a/src/services/chain-adapter/tron/index.ts +++ b/src/services/chain-adapter/tron/index.ts @@ -2,7 +2,7 @@ * Tron Chain Adapter exports */ -export { TronAdapter } from './adapter' +export { TronAdapter, createTronAdapter } from './adapter' export { TronIdentityService } from './identity-service' export { TronAssetService } from './asset-service' export { TronChainService } from './chain-service' diff --git a/src/services/chain-adapter/types.ts b/src/services/chain-adapter/types.ts index 1063f434..d2b3c913 100644 --- a/src/services/chain-adapter/types.ts +++ b/src/services/chain-adapter/types.ts @@ -167,15 +167,15 @@ export interface IChainAdapter { readonly chain: IChainService readonly staking: IStakingService | null - initialize(config: ChainConfig): Promise + initialize(): Promise dispose(): void } -export type AdapterFactory = (config: ChainConfig) => IChainAdapter +export type AdapterFactory = (chainId: string) => IChainAdapter export interface IAdapterRegistry { register(type: ChainConfigType, factory: AdapterFactory): void - setChainConfigs(configs: ChainConfig[]): void + registerChain(chainId: string, type: ChainConfigType): void getAdapter(chainId: string): IChainAdapter | null hasAdapter(chainId: string): boolean listAdapters(): string[] diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index 07a775fd..306a1492 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -1,6 +1,6 @@ export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType } from './types' -import { ChainConfigListSchema, ChainConfigSchema, ChainConfigSubscriptionSchema } from './schema' +import { ChainConfigListSchema, ChainConfigSchema, ChainConfigSubscriptionSchema, VersionedChainConfigFileSchema } from './schema' import { fetchSubscription, type FetchSubscriptionResult } from './subscription' import { loadChainConfigs, @@ -9,6 +9,8 @@ import { saveChainConfigs, saveUserPreferences, saveSubscriptionMeta, + loadDefaultVersion, + saveDefaultVersion, } from './storage' import type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType } from './types' @@ -39,8 +41,13 @@ const SUPPORTED_MAJOR_BY_TYPE: Record = { const DEFAULT_CHAINS_PATH = `${import.meta.env.BASE_URL}configs/default-chains.json` -let defaultChainsCache: ChainConfig[] | null = null -let defaultChainsLoading: Promise | null = null +interface DefaultChainsResult { + version: string + configs: ChainConfig[] +} + +let defaultChainsCache: DefaultChainsResult | null = null +let defaultChainsLoading: Promise | null = null function normalizeUnknownType(input: unknown): unknown { if (typeof input !== 'object' || input === null || Array.isArray(input)) return input @@ -72,7 +79,7 @@ function getDefaultChainsUrl(): string { return new URL(DEFAULT_CHAINS_PATH, base).toString() } -async function loadDefaultChainConfigs(): Promise { +async function loadDefaultChainConfigs(): Promise { if (defaultChainsCache) return defaultChainsCache if (defaultChainsLoading) return defaultChainsLoading @@ -92,10 +99,26 @@ async function loadDefaultChainConfigs(): Promise { } const json: unknown = await response.json() - // 传入 JSON 文件 URL,用于解析相对路径 - const parsed = parseConfigs(json, 'default', jsonUrl) - defaultChainsCache = parsed - return parsed + + // 解析带版本号的配置文件格式 + const parsed = VersionedChainConfigFileSchema.parse(json) + const configs = parsed.chains.map((chain) => normalizeUnknownType(chain)).map((chain) => { + const config = ChainConfigSchema.parse(chain) + const resolvedPaths = resolveIconPaths(config, jsonUrl) + return { + ...config, + ...resolvedPaths, + source: 'default' as const, + enabled: true, + } + }) + + const result: DefaultChainsResult = { + version: parsed.version, + configs, + } + defaultChainsCache = result + return result })() try { @@ -215,15 +238,35 @@ function collectWarnings(configs: ChainConfig[]): ChainConfigWarning[] { return warnings } +/** Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal */ +function compareSemver(a: string, b: string): number { + const partsA = a.split('.').map(Number) + const partsB = b.split('.').map(Number) + for (let i = 0; i < 3; i++) { + const numA = partsA[i] ?? 0 + const numB = partsB[i] ?? 0 + if (numA > numB) return 1 + if (numA < numB) return -1 + } + return 0 +} + export async function initialize(): Promise { - const defaults = await loadDefaultChainConfigs() + const { version: bundledVersion, configs: defaultConfigs } = await loadDefaultChainConfigs() - const [storedConfigs, enabledMap, subscription] = await Promise.all([ + const [storedConfigs, enabledMap, subscription, storedDefaultVersion] = await Promise.all([ loadChainConfigs(), loadUserPreferences(), loadSubscriptionMeta(), + loadDefaultVersion(), ]) + // 版本比较:bundled > stored 时强制合并默认配置 + const shouldForceMerge = compareSemver(bundledVersion, storedDefaultVersion ?? '0.0.0') > 0 + if (shouldForceMerge) { + await saveDefaultVersion(bundledVersion) + } + const manual = storedConfigs.filter((c) => c.source === 'manual') const subscriptionConfigs = @@ -231,7 +274,7 @@ export async function initialize(): Promise { ? storedConfigs.filter((c) => c.source === 'subscription') : [] - const merged = mergeBySource({ manual, subscription: subscriptionConfigs, defaults }) + const merged = mergeBySource({ manual, subscription: subscriptionConfigs, defaults: defaultConfigs }) const configs = applyEnabledMap(merged, enabledMap) const warnings = collectWarnings(configs) diff --git a/src/services/chain-config/schema.ts b/src/services/chain-config/schema.ts index 5e9ee263..251c403c 100644 --- a/src/services/chain-config/schema.ts +++ b/src/services/chain-config/schema.ts @@ -25,8 +25,8 @@ export const ChainConfigSourceSchema = z.enum(['default', 'subscription', 'manua export const ApiConfigSchema = z.object({ /** 提供商 base URL (e.g., https://walletapi.bfmeta.info) */ url: z.string().url(), - /** 该提供商对这条链的路径别名 (e.g., "bfm" for bfmeta) */ - path: z.string().min(1).max(20), + /** 该提供商对这条链的路径别名 (e.g., "bfm" for bfmeta),仅 bioforest 链需要 */ + path: z.string().min(1).max(20).optional(), }) /** 区块浏览器配置(可替换的外部依赖) */ @@ -70,6 +70,14 @@ export const ChainConfigSchema = z export const ChainConfigListSchema = z.array(ChainConfigSchema).min(1) +/** 带版本号的配置文件格式 */ +export const VersionedChainConfigFileSchema = z.object({ + /** 配置文件版本号 (semver, e.g., "2.0.0") */ + version: z.string().regex(/^\d+\.\d+\.\d+$/, 'version must be semver (e.g. "2.0.0")'), + /** 链配置列表 */ + chains: ChainConfigListSchema, +}) + export const ChainConfigSubscriptionSchema = z .object({ url: z.string().min(1), diff --git a/src/services/chain-config/service.ts b/src/services/chain-config/service.ts new file mode 100644 index 00000000..f9ad3e34 --- /dev/null +++ b/src/services/chain-config/service.ts @@ -0,0 +1,88 @@ +/** + * Chain Config Service + * + * 提供链配置查询的单一入口。 + * 代码只耦合 chainId,通过此服务获取配置。 + */ + +import { chainConfigStore, chainConfigSelectors } from '@/stores/chain-config' +import type { ChainConfig } from './types' + +/** 默认 RPC 端点(代码内置 fallback) */ +const DEFAULT_RPC_URLS: Record = { + ethereum: 'https://ethereum-rpc.publicnode.com', + binance: 'https://bsc-rpc.publicnode.com', + tron: 'https://api.trongrid.io', +} + +class ChainConfigService { + /** + * 获取链配置 + */ + getConfig(chainId: string): ChainConfig | null { + return chainConfigSelectors.getChainById(chainConfigStore.state, chainId) + } + + /** + * 获取链的 RPC URL + * 优先使用配置中的 api.url,fallback 到代码内置默认值 + */ + getRpcUrl(chainId: string): string { + const config = this.getConfig(chainId) + return config?.api?.url ?? DEFAULT_RPC_URLS[chainId] ?? '' + } + + /** + * 获取链的 API path (仅 bioforest 链需要) + */ + getApiPath(chainId: string): string { + const config = this.getConfig(chainId) + return config?.api?.path ?? chainId + } + + /** + * 获取链的 decimals + */ + getDecimals(chainId: string): number { + const config = this.getConfig(chainId) + return config?.decimals ?? 18 + } + + /** + * 获取链的 symbol + */ + getSymbol(chainId: string): string { + const config = this.getConfig(chainId) + return config?.symbol ?? '' + } + + /** + * 获取区块浏览器 URL + */ + getExplorerUrl(chainId: string): string | null { + const config = this.getConfig(chainId) + return config?.explorer?.url ?? null + } + + /** + * 获取交易查询 URL + */ + getTxQueryUrl(chainId: string, hash: string): string | null { + const config = this.getConfig(chainId) + const template = config?.explorer?.queryTx + if (!template) return null + return template.replace(':hash', hash).replace(':signature', hash) + } + + /** + * 获取地址查询 URL + */ + getAddressQueryUrl(chainId: string, address: string): string | null { + const config = this.getConfig(chainId) + const template = config?.explorer?.queryAddress + if (!template) return null + return template.replace(':address', address) + } +} + +export const chainConfigService = new ChainConfigService() diff --git a/src/services/chain-config/storage.ts b/src/services/chain-config/storage.ts index 19bc39b4..47323cd8 100644 --- a/src/services/chain-config/storage.ts +++ b/src/services/chain-config/storage.ts @@ -241,3 +241,27 @@ export async function resetChainConfigStorageForTests(): Promise { request.onblocked = () => resolve() }) } + +export async function saveDefaultVersion(version: string): Promise { + const db = await getDb() + const tx = db.transaction([STORE_PREFERENCES], 'readwrite') + const store = tx.objectStore(STORE_PREFERENCES) + + const record: PreferenceRecord = { key: 'defaultVersion', value: version } + store.put(record) + + await transactionDone(tx) +} + +export async function loadDefaultVersion(): Promise { + const db = await getDb() + const tx = db.transaction([STORE_PREFERENCES], 'readonly') + const store = tx.objectStore(STORE_PREFERENCES) + + const record = await requestToPromise(store.get('defaultVersion')) + await transactionDone(tx) + + if (!record || typeof record !== 'object') return null + const value = (record as PreferenceRecord).value + return typeof value === 'string' ? value : null +} From 3000d68f578b392fb7a18a6caaed474cc48428c0 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 18:40:28 +0800 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E7=89=88=E6=9C=AC=E6=A3=80=E6=B5=8B=E5=92=8C?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChainConfigMigrationError 错误类型 - 检测旧版数据(version == null 且 bundled >= 2.0.0)时抛出错误 - store 中添加 migrationRequired 状态 - 新增 MigrationRequiredView 组件显示迁移引导 - WalletTab 在检测到迁移需求时显示引导界面 - 添加 i18n 翻译 (zh-CN/en) --- .../common/migration-required-view.tsx | 44 +++++++++++++++++++ src/i18n/locales/en/settings.json | 5 ++- src/i18n/locales/zh-CN/settings.json | 5 ++- src/services/chain-config/index.ts | 29 +++++++++++- src/stackflow/activities/tabs/WalletTab.tsx | 8 ++++ src/stores/chain-config.ts | 21 ++++++++- src/stores/index.ts | 1 + 7 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 src/components/common/migration-required-view.tsx diff --git a/src/components/common/migration-required-view.tsx b/src/components/common/migration-required-view.tsx new file mode 100644 index 00000000..789390a6 --- /dev/null +++ b/src/components/common/migration-required-view.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next' +import { IconAlertTriangle, IconDatabase } from '@tabler/icons-react' +import { Button } from '@/components/ui/button' +import { useFlow } from '@/stackflow' + +/** + * 数据库迁移引导组件 + * 当检测到旧版数据需要迁移时显示 + */ +export function MigrationRequiredView() { + const { t } = useTranslation(['settings', 'common']) + const { push } = useFlow() + + const handleGoToStorage = () => { + push('SettingsStorageActivity', {}) + } + + return ( +
+
+
+ +
+ +
+

+ {t('settings:storage.migrationRequired', '数据需要迁移')} +

+

+ {t( + 'settings:storage.migrationDesc', + '检测到旧版本数据格式,需要清空本地数据库后才能继续使用。您的助记词和私钥不会受到影响,但需要重新导入钱包。' + )} +

+
+ + +
+
+ ) +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 3a100133..0bfca825 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -141,6 +141,9 @@ "available": "Available", "unavailable": "Unable to get storage info", "clearTitle": "Clear Data", - "clearDesc": "Clear all locally stored data including wallets, settings, and cache." + "clearDesc": "Clear all locally stored data including wallets, settings, and cache.", + "migrationRequired": "Data Migration Required", + "migrationDesc": "Detected old data format. Please clear the local database to continue. Your mnemonic and private keys are not affected, but you will need to re-import your wallet.", + "goToClear": "Go to Clear Data" } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 133a2121..9f7fac00 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -141,6 +141,9 @@ "available": "可用空间", "unavailable": "无法获取存储信息", "clearTitle": "清理数据", - "clearDesc": "清空所有本地存储的数据,包括钱包、设置和缓存。" + "clearDesc": "清空所有本地存储的数据,包括钱包、设置和缓存。", + "migrationRequired": "数据需要迁移", + "migrationDesc": "检测到旧版本数据格式,需要清空本地数据库后才能继续使用。您的助记词和私钥不会受到影响,但需要重新导入钱包。", + "goToClear": "前往清理数据" } } diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index 306a1492..e9b5481e 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -14,6 +14,20 @@ import { } from './storage' import type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType } from './types' +/** 数据库版本不兼容错误,需要用户清空数据 */ +export class ChainConfigMigrationError extends Error { + readonly code = 'MIGRATION_REQUIRED' + readonly storedVersion: string | null + readonly requiredVersion: string + + constructor(storedVersion: string | null, requiredVersion: string) { + super(`Database migration required: stored version ${storedVersion ?? 'unknown'} is incompatible with ${requiredVersion}`) + this.name = 'ChainConfigMigrationError' + this.storedVersion = storedVersion + this.requiredVersion = requiredVersion + } +} + export interface ChainConfigWarning { id: string kind: 'incompatible_major' @@ -99,9 +113,8 @@ async function loadDefaultChainConfigs(): Promise { } const json: unknown = await response.json() - - // 解析带版本号的配置文件格式 const parsed = VersionedChainConfigFileSchema.parse(json) + const configs = parsed.chains.map((chain) => normalizeUnknownType(chain)).map((chain) => { const config = ChainConfigSchema.parse(chain) const resolvedPaths = resolveIconPaths(config, jsonUrl) @@ -238,6 +251,12 @@ function collectWarnings(configs: ChainConfig[]): ChainConfigWarning[] { return warnings } +/** Parse major version from semver string */ +function parseMajorFromSemver(version: string): number { + const major = parseInt(version.split('.')[0] ?? '0', 10) + return Number.isNaN(major) ? 0 : major +} + /** Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal */ function compareSemver(a: string, b: string): number { const partsA = a.split('.').map(Number) @@ -261,6 +280,12 @@ export async function initialize(): Promise { loadDefaultVersion(), ]) + // 检测旧版数据:storedVersion 为 null 且 bundledVersion >= 2.0.0 + const bundledMajor = parseMajorFromSemver(bundledVersion) + if (storedDefaultVersion === null && bundledMajor >= 2) { + throw new ChainConfigMigrationError(storedDefaultVersion, bundledVersion) + } + // 版本比较:bundled > stored 时强制合并默认配置 const shouldForceMerge = compareSemver(bundledVersion, storedDefaultVersion ?? '0.0.0') > 0 if (shouldForceMerge) { diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index a9dec573..4c6da6d8 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -6,6 +6,7 @@ import { TransactionList } from "@/components/transaction/transaction-list"; import { WalletCardCarousel } from "@/components/wallet/wallet-card-carousel"; import { SwipeableTabs } from "@/components/layout/swipeable-tabs"; import { LoadingSpinner } from "@/components/common/loading-spinner"; +import { MigrationRequiredView } from "@/components/common/migration-required-view"; import { GradientButton } from "@/components/common/gradient-button"; import { Button } from "@/components/ui/button"; import { useWalletTheme } from "@/hooks/useWalletTheme"; @@ -25,6 +26,7 @@ import { useCurrentChainTokens, useHasWallet, useWalletInitialized, + useChainConfigMigrationRequired, walletActions, } from "@/stores"; import type { TransactionInfo } from "@/components/transaction/transaction-item"; @@ -51,6 +53,7 @@ export function WalletTab() { const haptics = useHaptics(); const { t } = useTranslation(["home", "wallet", "common", "transaction"]); + const migrationRequired = useChainConfigMigrationRequired(); const isInitialized = useWalletInitialized(); const hasWallet = useHasWallet(); const wallets = useWallets(); @@ -138,6 +141,11 @@ export function WalletTab() { [push] ); + // 需要迁移数据库 + if (migrationRequired) { + return ; + } + if (!isInitialized) { return (
diff --git a/src/stores/chain-config.ts b/src/stores/chain-config.ts index 543e172d..ac84d16a 100644 --- a/src/stores/chain-config.ts +++ b/src/stores/chain-config.ts @@ -7,6 +7,7 @@ import { refreshSubscription, setChainEnabled, setSubscriptionUrl, + ChainConfigMigrationError, type ChainConfig, type ChainConfigSnapshot, type ChainConfigSubscription, @@ -17,12 +18,15 @@ export interface ChainConfigState { snapshot: ChainConfigSnapshot | null isLoading: boolean error: string | null + /** 需要迁移数据库 */ + migrationRequired: boolean } const initialState: ChainConfigState = { snapshot: null, isLoading: false, error: null, + migrationRequired: false, } export const chainConfigStore = new Store(initialState) @@ -33,13 +37,22 @@ function toErrorMessage(error: unknown): string { } async function runAndUpdate(action: () => Promise): Promise { - chainConfigStore.setState((state) => ({ ...state, isLoading: true, error: null })) + chainConfigStore.setState((state) => ({ ...state, isLoading: true, error: null, migrationRequired: false })) try { const snapshot = await action() chainConfigStore.setState((state) => ({ ...state, snapshot, isLoading: false, error: null })) } catch (error) { - chainConfigStore.setState((state) => ({ ...state, isLoading: false, error: toErrorMessage(error) })) + if (error instanceof ChainConfigMigrationError) { + chainConfigStore.setState((state) => ({ + ...state, + isLoading: false, + error: error.message, + migrationRequired: true, + })) + } else { + chainConfigStore.setState((state) => ({ ...state, isLoading: false, error: toErrorMessage(error) })) + } } } @@ -162,3 +175,7 @@ export function useChainConfigLoading(): boolean { export function useChainConfigError(): string | null { return useStore(chainConfigStore, (state) => state.error) } + +export function useChainConfigMigrationRequired(): boolean { + return useStore(chainConfigStore, (state) => state.migrationRequired) +} diff --git a/src/stores/index.ts b/src/stores/index.ts index 86e5e1bd..302167b3 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -32,6 +32,7 @@ export { useChainConfigWarnings, useChainConfigLoading, useChainConfigError, + useChainConfigMigrationRequired, } from './chain-config' export type { ChainConfigState } from './chain-config' From 5427019d68f7911887cef53d1cc8a2fc387adb99 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 18:46:45 +0800 Subject: [PATCH 04/27] =?UTF-8?q?fix:=20=E5=9C=A8=20AppInitializer=20?= =?UTF-8?q?=E4=B8=AD=E7=AD=89=E5=BE=85=E9=93=BE=E9=85=8D=E7=BD=AE=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 确保链配置初始化完成后再渲染子组件 - 初始化过程中显示 loading - 迁移检测在初始化完成后生效,避免白屏 --- src/providers/AppInitializer.tsx | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/providers/AppInitializer.tsx b/src/providers/AppInitializer.tsx index 894a9cfc..14cfce70 100644 --- a/src/providers/AppInitializer.tsx +++ b/src/providers/AppInitializer.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, type ReactNode } from 'react' -import { addressBookActions, addressBookStore } from '@/stores' +import { addressBookActions, addressBookStore, useChainConfigMigrationRequired, chainConfigActions, useChainConfigLoading } from '@/stores' import { useStore } from '@tanstack/react-store' import { initializeThemeHue } from '@/hooks/useWalletTheme' +import { MigrationRequiredView } from '@/components/common/migration-required-view' +import { LoadingSpinner } from '@/components/common/loading-spinner' // 立即执行:在 React 渲染之前应用缓存的主题色,避免闪烁 initializeThemeHue() @@ -17,19 +19,32 @@ initializeThemeHue() export function AppInitializer({ children }: { children: ReactNode }) { const [isReady, setIsReady] = useState(false) const addressBookState = useStore(addressBookStore) + const migrationRequired = useChainConfigMigrationRequired() + const chainConfigLoading = useChainConfigLoading() useEffect(() => { // 统一初始化所有需要持久化的 store if (!addressBookState.isInitialized) { addressBookActions.initialize() } - setIsReady(true) + // 确保链配置初始化(可能已在 service-main 中调用,这里做保障) + chainConfigActions.initialize().finally(() => { + setIsReady(true) + }) }, []) // 只在挂载时执行一次 - // 可选:在 store 未初始化完成时显示 loading - // 但这里因为是同步初始化,所以直接渲染 - if (!isReady) { - return null + // 检测到需要迁移时,显示迁移界面 + if (migrationRequired) { + return + } + + // 等待链配置初始化完成 + if (!isReady || chainConfigLoading) { + return ( +
+ +
+ ) } return <>{children} From cc8e9e7dbbdf6b6cdf68af5d97c4ca16139633db Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 18:51:47 +0800 Subject: [PATCH 05/27] =?UTF-8?q?fix:=20MigrationRequiredView=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E5=AF=B9=20useFlow=20=E7=9A=84=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 window.location.href 直接导航,避免在 Stackflow context 外部使用 hook --- src/components/common/migration-required-view.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/common/migration-required-view.tsx b/src/components/common/migration-required-view.tsx index 789390a6..5697dd0c 100644 --- a/src/components/common/migration-required-view.tsx +++ b/src/components/common/migration-required-view.tsx @@ -1,18 +1,19 @@ import { useTranslation } from 'react-i18next' import { IconAlertTriangle, IconDatabase } from '@tabler/icons-react' import { Button } from '@/components/ui/button' -import { useFlow } from '@/stackflow' /** * 数据库迁移引导组件 * 当检测到旧版数据需要迁移时显示 + * + * 注意:此组件在 Stackflow context 外部渲染,不能使用 useFlow() */ export function MigrationRequiredView() { const { t } = useTranslation(['settings', 'common']) - const { push } = useFlow() const handleGoToStorage = () => { - push('SettingsStorageActivity', {}) + // 直接导航到存储管理页面(不依赖 Stackflow) + window.location.href = '/#/settings/storage' } return ( From ddb9ab000ea20fe5a18725997dbb1b8001216979 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 18:56:27 +0800 Subject: [PATCH 06/27] =?UTF-8?q?fix:=20=E5=B0=86=20I18nextProvider=20?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=20AppInitializer=20=E5=A4=96=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保 MigrationRequiredView 能使用 useTranslation --- src/frontend-main.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/frontend-main.tsx b/src/frontend-main.tsx index 177fefd0..361455d8 100644 --- a/src/frontend-main.tsx +++ b/src/frontend-main.tsx @@ -66,10 +66,10 @@ export function startFrontendMain(rootElement: HTMLElement): void { createRoot(rootElement).render( - - - - + + + + @@ -79,10 +79,10 @@ export function startFrontendMain(rootElement: HTMLElement): void { )} - - - - + + + + , ) From 0b15822739f5bba9c07acb312df357fc77552a12 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:06:18 +0800 Subject: [PATCH 07/27] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E6=95=B0=E6=8D=AE=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 lib/safe-parse.ts 通用验证工具 - 新增 wallet-storage/schema.ts zod schema 定义 - wallet-storage/service.ts 所有读取方法使用 schema 验证 - 无效数据会被过滤,不会导致白屏 --- src/lib/safe-parse.ts | 89 ++++++++++++++++++++++++++ src/services/wallet-storage/schema.ts | 72 +++++++++++++++++++++ src/services/wallet-storage/service.ts | 42 ++++++++++-- 3 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 src/lib/safe-parse.ts create mode 100644 src/services/wallet-storage/schema.ts diff --git a/src/lib/safe-parse.ts b/src/lib/safe-parse.ts new file mode 100644 index 00000000..c46212bb --- /dev/null +++ b/src/lib/safe-parse.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' + +/** + * 数据验证错误 + * 当外部数据(IndexedDB、localStorage、fetch)不符合预期 schema 时抛出 + */ +export class DataValidationError extends Error { + readonly code = 'DATA_VALIDATION_ERROR' + readonly source: string + readonly zodError: z.ZodError + + constructor(source: string, zodError: z.ZodError) { + const firstIssue = zodError.issues[0] + const path = firstIssue?.path.join('.') || 'root' + const message = firstIssue?.message || 'Unknown validation error' + super(`Invalid data from ${source}: ${path} - ${message}`) + this.name = 'DataValidationError' + this.source = source + this.zodError = zodError + } +} + +/** + * 安全解析:验证数据符合 schema,失败时抛出 DataValidationError + * 使用 passthrough 模式,允许多余字段 + */ +export function safeParse( + schema: z.ZodType, + data: unknown, + source: string +): T { + const result = schema.safeParse(data) + if (!result.success) { + throw new DataValidationError(source, result.error) + } + return result.data +} + +/** + * 安全解析数组:过滤掉无效项,返回有效项 + * 不会因为单个无效项而中断整个流程 + */ +export function safeParseArray( + itemSchema: z.ZodType, + data: unknown, + source: string +): T[] { + if (!Array.isArray(data)) { + console.warn(`[safeParse] Expected array from ${source}, got ${typeof data}`) + return [] + } + + const results: T[] = [] + for (let i = 0; i < data.length; i++) { + const result = itemSchema.safeParse(data[i]) + if (result.success) { + results.push(result.data) + } else { + console.warn(`[safeParse] Invalid item at index ${i} from ${source}:`, result.error.issues[0]) + } + } + return results +} + +/** + * 安全解析 JSON 字符串 + */ +export function safeParseJson( + schema: z.ZodType, + jsonString: string | null, + source: string +): T | null { + if (!jsonString) return null + + let parsed: unknown + try { + parsed = JSON.parse(jsonString) + } catch { + console.warn(`[safeParse] Invalid JSON from ${source}`) + return null + } + + const result = schema.safeParse(parsed) + if (!result.success) { + console.warn(`[safeParse] Schema validation failed for ${source}:`, result.error.issues[0]) + return null + } + return result.data +} diff --git a/src/services/wallet-storage/schema.ts b/src/services/wallet-storage/schema.ts new file mode 100644 index 00000000..95eefc75 --- /dev/null +++ b/src/services/wallet-storage/schema.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' + +/** + * Wallet Storage Zod Schemas + * 用于验证从 IndexedDB 读取的数据 + * 使用 passthrough() 允许多余字段,保证向前兼容 + */ + +/** 加密数据 schema */ +const EncryptedDataSchema = z.object({ + salt: z.string(), + iv: z.string(), + ciphertext: z.string(), +}).passthrough() + +/** 资产信息 schema */ +export const AssetInfoSchema = z.object({ + assetType: z.string(), + symbol: z.string(), + decimals: z.number(), + balance: z.string().default('0'), + contractAddress: z.string().optional(), + logoUrl: z.string().optional(), +}).passthrough() + +/** 链地址信息 schema */ +export const ChainAddressInfoSchema = z.object({ + addressKey: z.string(), + walletId: z.string(), + chain: z.string(), + address: z.string(), + publicKey: z.string().optional(), + encryptedPrivateKey: EncryptedDataSchema.optional(), + derivationPath: z.string().optional(), + assets: z.array(AssetInfoSchema).default([]), + isCustomAssets: z.boolean().default(false), + isFrozen: z.boolean().default(false), +}).passthrough() + +/** 钱包信息 schema */ +export const WalletInfoSchema = z.object({ + id: z.string(), + name: z.string(), + keyType: z.enum(['mnemonic', 'arbitrary', 'privateKey']), + primaryChain: z.string(), + primaryAddress: z.string(), + encryptedMnemonic: EncryptedDataSchema.optional(), + encryptedWalletLock: EncryptedDataSchema.optional(), + isBackedUp: z.boolean().default(false), + themeHue: z.number().optional(), + createdAt: z.number(), + updatedAt: z.number(), +}).passthrough() + +/** 钱包用户信息 schema */ +export const WalleterInfoSchema = z.object({ + name: z.string(), + passwordTips: z.string().optional(), + activeWalletId: z.string().nullable(), + biometricEnabled: z.boolean().default(false), + walletLockEnabled: z.boolean().default(true), + agreementAccepted: z.boolean().default(false), + createdAt: z.number(), + updatedAt: z.number(), +}).passthrough() + +/** 存储元数据 schema */ +export const StorageMetadataSchema = z.object({ + version: z.number(), + createdAt: z.number(), + lastMigratedAt: z.number().optional(), +}).passthrough() diff --git a/src/services/wallet-storage/service.ts b/src/services/wallet-storage/service.ts index d3558578..6d1f781b 100644 --- a/src/services/wallet-storage/service.ts +++ b/src/services/wallet-storage/service.ts @@ -1,5 +1,6 @@ import { openDB, type DBSchema, type IDBPDatabase } from 'idb' import { encrypt, decrypt, encryptWithRawKey, decryptWithRawKey, deriveEncryptionKeyFromMnemonic, deriveEncryptionKeyFromSecret } from '@/lib/crypto' +import { safeParseArray } from '@/lib/safe-parse' import { type WalleterInfo, type WalletInfo, @@ -11,6 +12,11 @@ import { WalletStorageError, WalletStorageErrorCode, } from './types' +import { + WalletInfoSchema, + ChainAddressInfoSchema, + WalleterInfoSchema, +} from './schema' const DB_NAME = 'bfm-wallet-db' const DB_VERSION = 1 @@ -145,7 +151,14 @@ export class WalletStorageService { /** 获取钱包用户信息 */ async getWalleterInfo(): Promise { this.ensureInitialized() - return (await this.db!.get('walleter', 'main')) ?? null + const raw = await this.db!.get('walleter', 'main') + if (!raw) return null + const result = WalleterInfoSchema.safeParse(raw) + if (!result.success) { + console.warn('[WalletStorage] Invalid walleter info:', result.error.issues[0]) + return null + } + return result.data as WalleterInfo } // ==================== 钱包管理 ==================== @@ -219,13 +232,21 @@ export class WalletStorageService { /** 获取钱包信息 */ async getWallet(walletId: string): Promise { this.ensureInitialized() - return (await this.db!.get('wallets', walletId)) ?? null + const raw = await this.db!.get('wallets', walletId) + if (!raw) return null + const result = WalletInfoSchema.safeParse(raw) + if (!result.success) { + console.warn('[WalletStorage] Invalid wallet info:', result.error.issues[0]) + return null + } + return result.data as WalletInfo } /** 获取所有钱包 */ async getAllWallets(): Promise { this.ensureInitialized() - return this.db!.getAll('wallets') + const raw = await this.db!.getAll('wallets') + return safeParseArray(WalletInfoSchema, raw, 'indexeddb:wallets') as WalletInfo[] } /** 更新钱包信息 */ @@ -495,19 +516,28 @@ export class WalletStorageService { /** 获取链地址信息 */ async getChainAddress(addressKey: string): Promise { this.ensureInitialized() - return (await this.db!.get('chainAddresses', addressKey)) ?? null + const raw = await this.db!.get('chainAddresses', addressKey) + if (!raw) return null + const result = ChainAddressInfoSchema.safeParse(raw) + if (!result.success) { + console.warn('[WalletStorage] Invalid chain address:', result.error.issues[0]) + return null + } + return result.data as ChainAddressInfo } /** 获取钱包的所有链地址 */ async getWalletChainAddresses(walletId: string): Promise { this.ensureInitialized() - return this.db!.getAllFromIndex('chainAddresses', 'by-wallet', walletId) + const raw = await this.db!.getAllFromIndex('chainAddresses', 'by-wallet', walletId) + return safeParseArray(ChainAddressInfoSchema, raw, 'indexeddb:chainAddresses') as ChainAddressInfo[] } /** 获取链的所有地址 */ async getChainAddresses(chain: string): Promise { this.ensureInitialized() - return this.db!.getAllFromIndex('chainAddresses', 'by-chain', chain) + const raw = await this.db!.getAllFromIndex('chainAddresses', 'by-chain', chain) + return safeParseArray(ChainAddressInfoSchema, raw, 'indexeddb:chainAddresses') as ChainAddressInfo[] } /** 更新资产信息 */ From 293ed7e9b7e87348f78e6a511cf5e43fcb3f2f39 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:19:51 +0800 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20=E9=92=B1=E5=8C=85=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E7=89=88=E6=9C=AC=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WALLET_STORAGE_VERSION 升级到 2 - 新增 WalletStorageMigrationError 错误类 - wallet-storage/service.ts 初始化时检测版本不兼容 - wallet store 捕获错误,设置 migrationRequired 状态 - AppInitializer 同时检查链配置和钱包的迁移状态 --- src/providers/AppInitializer.tsx | 28 +- src/services/wallet-storage/index.ts | 1 + src/services/wallet-storage/service.ts | 555 ++++++++++++------------- src/services/wallet-storage/types.ts | 16 +- src/stores/hooks.ts | 5 + src/stores/index.ts | 1 + src/stores/wallet.ts | 15 + 7 files changed, 319 insertions(+), 302 deletions(-) diff --git a/src/providers/AppInitializer.tsx b/src/providers/AppInitializer.tsx index 14cfce70..50f8e9c5 100644 --- a/src/providers/AppInitializer.tsx +++ b/src/providers/AppInitializer.tsx @@ -1,5 +1,14 @@ import { useEffect, useState, type ReactNode } from 'react' -import { addressBookActions, addressBookStore, useChainConfigMigrationRequired, chainConfigActions, useChainConfigLoading } from '@/stores' +import { + addressBookActions, + addressBookStore, + useChainConfigMigrationRequired, + chainConfigActions, + useChainConfigLoading, + useWalletMigrationRequired, + walletActions, + useWalletLoading, +} from '@/stores' import { useStore } from '@tanstack/react-store' import { initializeThemeHue } from '@/hooks/useWalletTheme' import { MigrationRequiredView } from '@/components/common/migration-required-view' @@ -19,27 +28,32 @@ initializeThemeHue() export function AppInitializer({ children }: { children: ReactNode }) { const [isReady, setIsReady] = useState(false) const addressBookState = useStore(addressBookStore) - const migrationRequired = useChainConfigMigrationRequired() + const chainConfigMigrationRequired = useChainConfigMigrationRequired() + const walletMigrationRequired = useWalletMigrationRequired() const chainConfigLoading = useChainConfigLoading() + const walletLoading = useWalletLoading() useEffect(() => { // 统一初始化所有需要持久化的 store if (!addressBookState.isInitialized) { addressBookActions.initialize() } - // 确保链配置初始化(可能已在 service-main 中调用,这里做保障) - chainConfigActions.initialize().finally(() => { + // 初始化链配置和钱包 + Promise.all([ + chainConfigActions.initialize(), + walletActions.initialize(), + ]).finally(() => { setIsReady(true) }) }, []) // 只在挂载时执行一次 // 检测到需要迁移时,显示迁移界面 - if (migrationRequired) { + if (chainConfigMigrationRequired || walletMigrationRequired) { return } - // 等待链配置初始化完成 - if (!isReady || chainConfigLoading) { + // 等待初始化完成 + if (!isReady || chainConfigLoading || walletLoading) { return (
diff --git a/src/services/wallet-storage/index.ts b/src/services/wallet-storage/index.ts index 2f8e1967..02e3abe4 100644 --- a/src/services/wallet-storage/index.ts +++ b/src/services/wallet-storage/index.ts @@ -14,4 +14,5 @@ export { WALLET_STORAGE_VERSION, WalletStorageError, WalletStorageErrorCode, + WalletStorageMigrationError, } from './types' diff --git a/src/services/wallet-storage/service.ts b/src/services/wallet-storage/service.ts index 6d1f781b..a75fb06b 100644 --- a/src/services/wallet-storage/service.ts +++ b/src/services/wallet-storage/service.ts @@ -1,6 +1,13 @@ -import { openDB, type DBSchema, type IDBPDatabase } from 'idb' -import { encrypt, decrypt, encryptWithRawKey, decryptWithRawKey, deriveEncryptionKeyFromMnemonic, deriveEncryptionKeyFromSecret } from '@/lib/crypto' -import { safeParseArray } from '@/lib/safe-parse' +import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; +import { + encrypt, + decrypt, + encryptWithRawKey, + decryptWithRawKey, + deriveEncryptionKeyFromMnemonic, + deriveEncryptionKeyFromSecret, +} from '@/lib/crypto'; +import { safeParseArray } from '@/lib/safe-parse'; import { type WalleterInfo, type WalletInfo, @@ -11,83 +18,80 @@ import { WALLET_STORAGE_VERSION, WalletStorageError, WalletStorageErrorCode, -} from './types' -import { - WalletInfoSchema, - ChainAddressInfoSchema, - WalleterInfoSchema, -} from './schema' + WalletStorageMigrationError, +} from './types'; +import { WalletInfoSchema, ChainAddressInfoSchema, WalleterInfoSchema } from './schema'; -const DB_NAME = 'bfm-wallet-db' -const DB_VERSION = 1 +const DB_NAME = 'bfm-wallet-db'; +const DB_VERSION = 1; interface WalletDBSchema extends DBSchema { metadata: { - key: string - value: StorageMetadata - } + key: string; + value: StorageMetadata; + }; walleter: { - key: string - value: WalleterInfo - } + key: string; + value: WalleterInfo; + }; wallets: { - key: string - value: WalletInfo - indexes: { 'by-chain': string } - } + key: string; + value: WalletInfo; + indexes: { 'by-chain': string }; + }; chainAddresses: { - key: string - value: ChainAddressInfo - indexes: { 'by-wallet': string; 'by-chain': string } - } + key: string; + value: ChainAddressInfo; + indexes: { 'by-wallet': string; 'by-chain': string }; + }; addressBook: { - key: string - value: AddressBookEntry - indexes: { 'by-chain': string } - } + key: string; + value: AddressBookEntry; + indexes: { 'by-chain': string }; + }; } /** 钱包存储服务 */ export class WalletStorageService { - private db: IDBPDatabase | null = null - private initialized = false + private db: IDBPDatabase | null = null; + private initialized = false; /** 初始化存储服务 */ async initialize(): Promise { - if (this.initialized) return + if (this.initialized) return; this.db = await openDB(DB_NAME, DB_VERSION, { upgrade(db, oldVersion, _newVersion, transaction) { // metadata store - let metadataStore: ReturnType> | undefined + let metadataStore: ReturnType> | undefined; if (!db.objectStoreNames.contains('metadata')) { - metadataStore = db.createObjectStore('metadata') + metadataStore = db.createObjectStore('metadata'); } // walleter store if (!db.objectStoreNames.contains('walleter')) { - db.createObjectStore('walleter') + db.createObjectStore('walleter'); } // wallets store if (!db.objectStoreNames.contains('wallets')) { - const walletStore = db.createObjectStore('wallets', { keyPath: 'id' }) - walletStore.createIndex('by-chain', 'primaryChain') + const walletStore = db.createObjectStore('wallets', { keyPath: 'id' }); + walletStore.createIndex('by-chain', 'primaryChain'); } // chainAddresses store if (!db.objectStoreNames.contains('chainAddresses')) { const addressStore = db.createObjectStore('chainAddresses', { keyPath: 'addressKey', - }) - addressStore.createIndex('by-wallet', 'walletId') - addressStore.createIndex('by-chain', 'chain') + }); + addressStore.createIndex('by-wallet', 'walletId'); + addressStore.createIndex('by-chain', 'chain'); } // addressBook store if (!db.objectStoreNames.contains('addressBook')) { - const bookStore = db.createObjectStore('addressBook', { keyPath: 'id' }) - bookStore.createIndex('by-chain', 'chain') + const bookStore = db.createObjectStore('addressBook', { keyPath: 'id' }); + bookStore.createIndex('by-chain', 'chain'); } // Initialize metadata on first creation @@ -97,38 +101,45 @@ export class WalletStorageService { version: WALLET_STORAGE_VERSION, createdAt: Date.now(), }, - 'main' - ) + 'main', + ); } else if (oldVersion === 0) { transaction.objectStore('metadata').put( { version: WALLET_STORAGE_VERSION, createdAt: Date.now(), }, - 'main' - ) + 'main', + ); } }, - }) + }); // Set initialized before migrations so getMetadata works - this.initialized = true + this.initialized = true; + + // 检测版本不兼容 + const metadata = await this.getMetadata(); + const storedVersion = metadata?.version ?? 0; + if (storedVersion > 0 && storedVersion < WALLET_STORAGE_VERSION) { + throw new WalletStorageMigrationError(storedVersion, WALLET_STORAGE_VERSION); + } // Run migrations if needed - await this.runMigrations() + await this.runMigrations(); } /** 检查是否已初始化 */ isInitialized(): boolean { - return this.initialized + return this.initialized; } private ensureInitialized(): void { if (!this.initialized || !this.db) { throw new WalletStorageError( WalletStorageErrorCode.NOT_INITIALIZED, - 'Storage service not initialized. Call initialize() first.' - ) + 'Storage service not initialized. Call initialize() first.', + ); } } @@ -136,29 +147,29 @@ export class WalletStorageService { /** 获取存储元数据 */ async getMetadata(): Promise { - this.ensureInitialized() - return (await this.db!.get('metadata', 'main')) ?? null + this.ensureInitialized(); + return (await this.db!.get('metadata', 'main')) ?? null; } // ==================== 钱包用户 ==================== /** 保存钱包用户信息 */ async saveWalleterInfo(info: WalleterInfo): Promise { - this.ensureInitialized() - await this.db!.put('walleter', info, 'main') + this.ensureInitialized(); + await this.db!.put('walleter', info, 'main'); } /** 获取钱包用户信息 */ async getWalleterInfo(): Promise { - this.ensureInitialized() - const raw = await this.db!.get('walleter', 'main') - if (!raw) return null - const result = WalleterInfoSchema.safeParse(raw) + this.ensureInitialized(); + const raw = await this.db!.get('walleter', 'main'); + if (!raw) return null; + const result = WalleterInfoSchema.safeParse(raw); if (!result.success) { - console.warn('[WalletStorage] Invalid walleter info:', result.error.issues[0]) - return null + console.warn('[WalletStorage] Invalid walleter info:', result.error.issues[0]); + return null; } - return result.data as WalleterInfo + return result.data as WalleterInfo; } // ==================== 钱包管理 ==================== @@ -167,341 +178,303 @@ export class WalletStorageService { async createWallet( wallet: Omit, mnemonic: string, - walletLock: string + walletLock: string, ): Promise { - this.ensureInitialized() + this.ensureInitialized(); // 验证参数 if (!mnemonic || typeof mnemonic !== 'string') { - throw new WalletStorageError( - WalletStorageErrorCode.ENCRYPTION_FAILED, - 'Invalid mnemonic: mnemonic is required' - ) + throw new WalletStorageError(WalletStorageErrorCode.ENCRYPTION_FAILED, 'Invalid mnemonic: mnemonic is required'); } if (!walletLock || typeof walletLock !== 'string') { throw new WalletStorageError( WalletStorageErrorCode.ENCRYPTION_FAILED, - 'Invalid walletLock: wallet lock password is required' - ) + 'Invalid walletLock: wallet lock password is required', + ); } // 检查 crypto.subtle 可用性(仅在安全上下文中可用) if (typeof crypto === 'undefined' || !crypto.subtle) { throw new WalletStorageError( WalletStorageErrorCode.ENCRYPTION_FAILED, - 'Web Crypto API is not available. Please use HTTPS or localhost.' - ) + 'Web Crypto API is not available. Please use HTTPS or localhost.', + ); } try { // 双向加密: // 1. 使用钱包锁加密助记词/密钥 - const encryptedMnemonic = await encrypt(mnemonic, walletLock) + const encryptedMnemonic = await encrypt(mnemonic, walletLock); // 2. 根据密钥类型选择派生方法加密钱包锁 // - mnemonic: 使用 BIP32 派生(兼容标准助记词) // - arbitrary/privateKey: 使用 SHA256 派生(支持任意字符串) - const secretKey = wallet.keyType === 'mnemonic' - ? deriveEncryptionKeyFromMnemonic(mnemonic) - : deriveEncryptionKeyFromSecret(mnemonic) - const encryptedWalletLock = await encryptWithRawKey(walletLock, secretKey) - + const secretKey = + wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic); + const encryptedWalletLock = await encryptWithRawKey(walletLock, secretKey); + const walletWithEncryption: WalletInfo = { ...wallet, encryptedMnemonic, encryptedWalletLock, - } + }; - await this.db!.put('wallets', walletWithEncryption) - return walletWithEncryption + await this.db!.put('wallets', walletWithEncryption); + return walletWithEncryption; } catch (err) { - const message = err instanceof Error ? err.message : String(err) + const message = err instanceof Error ? err.message : String(err); throw new WalletStorageError( WalletStorageErrorCode.ENCRYPTION_FAILED, `Failed to encrypt wallet data: ${message}`, - err instanceof Error ? err : undefined - ) + err instanceof Error ? err : undefined, + ); } } /** 保存钱包(无加密,用于导入已有加密数据) */ async saveWallet(wallet: WalletInfo): Promise { - this.ensureInitialized() - await this.db!.put('wallets', wallet) + this.ensureInitialized(); + await this.db!.put('wallets', wallet); } /** 获取钱包信息 */ async getWallet(walletId: string): Promise { - this.ensureInitialized() - const raw = await this.db!.get('wallets', walletId) - if (!raw) return null - const result = WalletInfoSchema.safeParse(raw) + this.ensureInitialized(); + const raw = await this.db!.get('wallets', walletId); + if (!raw) return null; + const result = WalletInfoSchema.safeParse(raw); if (!result.success) { - console.warn('[WalletStorage] Invalid wallet info:', result.error.issues[0]) - return null + console.warn('[WalletStorage] Invalid wallet info:', result.error.issues[0]); + return null; } - return result.data as WalletInfo + return result.data as WalletInfo; } /** 获取所有钱包 */ async getAllWallets(): Promise { - this.ensureInitialized() - const raw = await this.db!.getAll('wallets') - return safeParseArray(WalletInfoSchema, raw, 'indexeddb:wallets') as WalletInfo[] + this.ensureInitialized(); + const raw = await this.db!.getAll('wallets'); + return safeParseArray(WalletInfoSchema, raw, 'indexeddb:wallets') as WalletInfo[]; } /** 更新钱包信息 */ - async updateWallet( - walletId: string, - updates: Partial> - ): Promise { - this.ensureInitialized() + async updateWallet(walletId: string, updates: Partial>): Promise { + this.ensureInitialized(); - const wallet = await this.getWallet(walletId) + const wallet = await this.getWallet(walletId); if (!wallet) { - throw new WalletStorageError( - WalletStorageErrorCode.WALLET_NOT_FOUND, - `Wallet not found: ${walletId}` - ) + throw new WalletStorageError(WalletStorageErrorCode.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); } await this.db!.put('wallets', { ...wallet, ...updates, updatedAt: Date.now(), - }) + }); } /** 删除钱包 */ async deleteWallet(walletId: string): Promise { - this.ensureInitialized() + this.ensureInitialized(); // Delete wallet - await this.db!.delete('wallets', walletId) + await this.db!.delete('wallets', walletId); // Delete associated chain addresses - const addresses = await this.getWalletChainAddresses(walletId) - const tx = this.db!.transaction('chainAddresses', 'readwrite') - await Promise.all(addresses.map((addr) => tx.store.delete(addr.addressKey))) - await tx.done + const addresses = await this.getWalletChainAddresses(walletId); + const tx = this.db!.transaction('chainAddresses', 'readwrite'); + await Promise.all(addresses.map((addr) => tx.store.delete(addr.addressKey))); + await tx.done; } // ==================== 助记词/私钥 ==================== /** 获取解密的助记词 */ async getMnemonic(walletId: string, password: string): Promise { - this.ensureInitialized() + this.ensureInitialized(); - const wallet = await this.getWallet(walletId) + const wallet = await this.getWallet(walletId); if (!wallet) { - throw new WalletStorageError( - WalletStorageErrorCode.WALLET_NOT_FOUND, - `Wallet not found: ${walletId}` - ) + throw new WalletStorageError(WalletStorageErrorCode.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); } if (!wallet.encryptedMnemonic) { throw new WalletStorageError( WalletStorageErrorCode.DECRYPTION_FAILED, - 'No encrypted mnemonic found for this wallet' - ) + 'No encrypted mnemonic found for this wallet', + ); } try { - return await decrypt(wallet.encryptedMnemonic, password) + return await decrypt(wallet.encryptedMnemonic, password); } catch (err) { throw new WalletStorageError( WalletStorageErrorCode.DECRYPTION_FAILED, 'Failed to decrypt mnemonic. Wrong password or corrupted data.', - err instanceof Error ? err : undefined - ) + err instanceof Error ? err : undefined, + ); } } /** 更新钱包锁加密(钱包锁变更时) */ - async updateWalletLockEncryption( - walletId: string, - oldWalletLock: string, - newWalletLock: string - ): Promise { - this.ensureInitialized() - - const wallet = await this.getWallet(walletId) + async updateWalletLockEncryption(walletId: string, oldWalletLock: string, newWalletLock: string): Promise { + this.ensureInitialized(); + + const wallet = await this.getWallet(walletId); if (!wallet) { - throw new WalletStorageError( - WalletStorageErrorCode.WALLET_NOT_FOUND, - `Wallet not found: ${walletId}` - ) + throw new WalletStorageError(WalletStorageErrorCode.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); } // 用旧钱包锁解密助记词 - const mnemonic = await this.getMnemonic(walletId, oldWalletLock) + const mnemonic = await this.getMnemonic(walletId, oldWalletLock); // 重新双向加密 try { // 1. 使用新钱包锁加密助记词 - const encryptedMnemonic = await encrypt(mnemonic, newWalletLock) + const encryptedMnemonic = await encrypt(mnemonic, newWalletLock); // 2. 根据密钥类型选择派生方法加密新钱包锁 - const secretKey = wallet.keyType === 'mnemonic' - ? deriveEncryptionKeyFromMnemonic(mnemonic) - : deriveEncryptionKeyFromSecret(mnemonic) - const encryptedWalletLock = await encryptWithRawKey(newWalletLock, secretKey) - - await this.updateWallet(walletId, { encryptedMnemonic, encryptedWalletLock }) + const secretKey = + wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic); + const encryptedWalletLock = await encryptWithRawKey(newWalletLock, secretKey); + + await this.updateWallet(walletId, { encryptedMnemonic, encryptedWalletLock }); } catch (err) { throw new WalletStorageError( WalletStorageErrorCode.ENCRYPTION_FAILED, 'Failed to re-encrypt wallet data', - err instanceof Error ? err : undefined - ) + err instanceof Error ? err : undefined, + ); } } /** 验证助记词是否正确(不修改数据) */ - async verifyMnemonic( - walletId: string, - mnemonic: string - ): Promise { - this.ensureInitialized() + async verifyMnemonic(walletId: string, mnemonic: string): Promise { + this.ensureInitialized(); - const wallet = await this.getWallet(walletId) + const wallet = await this.getWallet(walletId); if (!wallet) { - throw new WalletStorageError( - WalletStorageErrorCode.WALLET_NOT_FOUND, - `Wallet not found: ${walletId}` - ) + throw new WalletStorageError(WalletStorageErrorCode.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); } if (!wallet.encryptedWalletLock) { throw new WalletStorageError( WalletStorageErrorCode.DECRYPTION_FAILED, - 'No encrypted wallet lock found for this wallet' - ) + 'No encrypted wallet lock found for this wallet', + ); } // 验证助记词/密钥:尝试用派生密钥解密钱包锁 try { - const secretKey = wallet.keyType === 'mnemonic' - ? deriveEncryptionKeyFromMnemonic(mnemonic) - : deriveEncryptionKeyFromSecret(mnemonic) - await decryptWithRawKey(wallet.encryptedWalletLock, secretKey) - return true + const secretKey = + wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic); + await decryptWithRawKey(wallet.encryptedWalletLock, secretKey); + return true; } catch { - return false + return false; } } /** 使用助记词重置钱包锁 */ - async resetWalletLockByMnemonic( - walletId: string, - mnemonic: string, - newWalletLock: string - ): Promise { - this.ensureInitialized() + async resetWalletLockByMnemonic(walletId: string, mnemonic: string, newWalletLock: string): Promise { + this.ensureInitialized(); - const wallet = await this.getWallet(walletId) + const wallet = await this.getWallet(walletId); if (!wallet) { - throw new WalletStorageError( - WalletStorageErrorCode.WALLET_NOT_FOUND, - `Wallet not found: ${walletId}` - ) + throw new WalletStorageError(WalletStorageErrorCode.WALLET_NOT_FOUND, `Wallet not found: ${walletId}`); } if (!wallet.encryptedWalletLock) { throw new WalletStorageError( WalletStorageErrorCode.DECRYPTION_FAILED, - 'No encrypted wallet lock found for this wallet' - ) + 'No encrypted wallet lock found for this wallet', + ); } // 验证助记词/密钥:尝试用派生密钥解密钱包锁 - const secretKey = wallet.keyType === 'mnemonic' - ? deriveEncryptionKeyFromMnemonic(mnemonic) - : deriveEncryptionKeyFromSecret(mnemonic) + const secretKey = + wallet.keyType === 'mnemonic' + ? deriveEncryptionKeyFromMnemonic(mnemonic) + : deriveEncryptionKeyFromSecret(mnemonic); try { - await decryptWithRawKey(wallet.encryptedWalletLock, secretKey) + await decryptWithRawKey(wallet.encryptedWalletLock, secretKey); } catch { throw new WalletStorageError( WalletStorageErrorCode.INVALID_PASSWORD, - 'Invalid mnemonic/secret: failed to decrypt wallet lock' - ) + 'Invalid mnemonic/secret: failed to decrypt wallet lock', + ); } // 验证通过,重新双向加密 try { // 1. 使用新钱包锁加密助记词/密钥 - const encryptedMnemonic = await encrypt(mnemonic, newWalletLock) + const encryptedMnemonic = await encrypt(mnemonic, newWalletLock); // 2. 使用派生密钥加密新钱包锁 - const encryptedWalletLock = await encryptWithRawKey(newWalletLock, secretKey) - - await this.updateWallet(walletId, { encryptedMnemonic, encryptedWalletLock }) + const encryptedWalletLock = await encryptWithRawKey(newWalletLock, secretKey); + + await this.updateWallet(walletId, { encryptedMnemonic, encryptedWalletLock }); } catch (err) { throw new WalletStorageError( WalletStorageErrorCode.ENCRYPTION_FAILED, 'Failed to re-encrypt wallet data', - err instanceof Error ? err : undefined - ) + err instanceof Error ? err : undefined, + ); } } /** 存储私钥 */ - async savePrivateKey( - addressKey: string, - privateKey: string, - password: string - ): Promise { - this.ensureInitialized() - - const address = await this.getChainAddress(addressKey) + async savePrivateKey(addressKey: string, privateKey: string, password: string): Promise { + this.ensureInitialized(); + + const address = await this.getChainAddress(addressKey); if (!address) { - throw new WalletStorageError( - WalletStorageErrorCode.ADDRESS_NOT_FOUND, - `Chain address not found: ${addressKey}` - ) + throw new WalletStorageError(WalletStorageErrorCode.ADDRESS_NOT_FOUND, `Chain address not found: ${addressKey}`); } try { - const encryptedPrivateKey = await encrypt(privateKey, password) + const encryptedPrivateKey = await encrypt(privateKey, password); await this.db!.put('chainAddresses', { ...address, encryptedPrivateKey, - }) + }); } catch (err) { throw new WalletStorageError( WalletStorageErrorCode.ENCRYPTION_FAILED, 'Failed to encrypt private key', - err instanceof Error ? err : undefined - ) + err instanceof Error ? err : undefined, + ); } } /** 获取解密的私钥 */ async getPrivateKey(addressKey: string, password: string): Promise { - this.ensureInitialized() + this.ensureInitialized(); - const address = await this.getChainAddress(addressKey) + const address = await this.getChainAddress(addressKey); if (!address) { - throw new WalletStorageError( - WalletStorageErrorCode.ADDRESS_NOT_FOUND, - `Chain address not found: ${addressKey}` - ) + throw new WalletStorageError(WalletStorageErrorCode.ADDRESS_NOT_FOUND, `Chain address not found: ${addressKey}`); } if (!address.encryptedPrivateKey) { throw new WalletStorageError( WalletStorageErrorCode.DECRYPTION_FAILED, - 'No encrypted private key found for this address' - ) + 'No encrypted private key found for this address', + ); } try { - return await decrypt(address.encryptedPrivateKey, password) + return await decrypt(address.encryptedPrivateKey, password); } catch (err) { throw new WalletStorageError( WalletStorageErrorCode.DECRYPTION_FAILED, 'Failed to decrypt private key. Wrong password or corrupted data.', - err instanceof Error ? err : undefined - ) + err instanceof Error ? err : undefined, + ); } } @@ -509,129 +482,123 @@ export class WalletStorageService { /** 保存链地址信息 */ async saveChainAddress(info: ChainAddressInfo): Promise { - this.ensureInitialized() - await this.db!.put('chainAddresses', info) + this.ensureInitialized(); + await this.db!.put('chainAddresses', info); } /** 获取链地址信息 */ async getChainAddress(addressKey: string): Promise { - this.ensureInitialized() - const raw = await this.db!.get('chainAddresses', addressKey) - if (!raw) return null - const result = ChainAddressInfoSchema.safeParse(raw) + this.ensureInitialized(); + const raw = await this.db!.get('chainAddresses', addressKey); + if (!raw) return null; + const result = ChainAddressInfoSchema.safeParse(raw); if (!result.success) { - console.warn('[WalletStorage] Invalid chain address:', result.error.issues[0]) - return null + console.warn('[WalletStorage] Invalid chain address:', result.error.issues[0]); + return null; } - return result.data as ChainAddressInfo + return result.data as ChainAddressInfo; } /** 获取钱包的所有链地址 */ async getWalletChainAddresses(walletId: string): Promise { - this.ensureInitialized() - const raw = await this.db!.getAllFromIndex('chainAddresses', 'by-wallet', walletId) - return safeParseArray(ChainAddressInfoSchema, raw, 'indexeddb:chainAddresses') as ChainAddressInfo[] + this.ensureInitialized(); + const raw = await this.db!.getAllFromIndex('chainAddresses', 'by-wallet', walletId); + return safeParseArray(ChainAddressInfoSchema, raw, 'indexeddb:chainAddresses') as ChainAddressInfo[]; } /** 获取链的所有地址 */ async getChainAddresses(chain: string): Promise { - this.ensureInitialized() - const raw = await this.db!.getAllFromIndex('chainAddresses', 'by-chain', chain) - return safeParseArray(ChainAddressInfoSchema, raw, 'indexeddb:chainAddresses') as ChainAddressInfo[] + this.ensureInitialized(); + const raw = await this.db!.getAllFromIndex('chainAddresses', 'by-chain', chain); + return safeParseArray(ChainAddressInfoSchema, raw, 'indexeddb:chainAddresses') as ChainAddressInfo[]; } /** 更新资产信息 */ async updateAssets(addressKey: string, assets: AssetInfo[]): Promise { - this.ensureInitialized() + this.ensureInitialized(); - const address = await this.getChainAddress(addressKey) + const address = await this.getChainAddress(addressKey); if (!address) { - throw new WalletStorageError( - WalletStorageErrorCode.ADDRESS_NOT_FOUND, - `Chain address not found: ${addressKey}` - ) + throw new WalletStorageError(WalletStorageErrorCode.ADDRESS_NOT_FOUND, `Chain address not found: ${addressKey}`); } await this.db!.put('chainAddresses', { ...address, assets, isCustomAssets: true, - }) + }); } /** 删除链地址 */ async deleteChainAddress(addressKey: string): Promise { - this.ensureInitialized() - await this.db!.delete('chainAddresses', addressKey) + this.ensureInitialized(); + await this.db!.delete('chainAddresses', addressKey); } // ==================== 地址簿 ==================== /** 保存地址簿条目 */ async saveAddressBookEntry(entry: AddressBookEntry): Promise { - this.ensureInitialized() - await this.db!.put('addressBook', entry) + this.ensureInitialized(); + await this.db!.put('addressBook', entry); } /** 获取地址簿条目 */ async getAddressBookEntry(id: string): Promise { - this.ensureInitialized() - return (await this.db!.get('addressBook', id)) ?? null + this.ensureInitialized(); + return (await this.db!.get('addressBook', id)) ?? null; } /** 获取所有地址簿条目 */ async getAllAddressBookEntries(): Promise { - this.ensureInitialized() - return this.db!.getAll('addressBook') + this.ensureInitialized(); + return this.db!.getAll('addressBook'); } /** 获取链的地址簿条目 */ async getChainAddressBookEntries(chain: string): Promise { - this.ensureInitialized() - return this.db!.getAllFromIndex('addressBook', 'by-chain', chain) + this.ensureInitialized(); + return this.db!.getAllFromIndex('addressBook', 'by-chain', chain); } /** 删除地址簿条目 */ async deleteAddressBookEntry(id: string): Promise { - this.ensureInitialized() - await this.db!.delete('addressBook', id) + this.ensureInitialized(); + await this.db!.delete('addressBook', id); } // ==================== 数据管理 ==================== /** 清除所有数据 */ async clearAll(): Promise { - this.ensureInitialized() + this.ensureInitialized(); - const tx = this.db!.transaction( - ['walleter', 'wallets', 'chainAddresses', 'addressBook'], - 'readwrite' - ) + const tx = this.db!.transaction(['walleter', 'wallets', 'chainAddresses', 'addressBook'], 'readwrite'); await Promise.all([ tx.objectStore('walleter').clear(), tx.objectStore('wallets').clear(), tx.objectStore('chainAddresses').clear(), tx.objectStore('addressBook').clear(), - ]) + ]); - await tx.done + await tx.done; } /** 关闭数据库连接 */ close(): void { if (this.db) { - this.db.close() - this.db = null - this.initialized = false + this.db.close(); + this.db = null; + this.initialized = false; } } // ==================== 数据迁移 ==================== private async runMigrations(): Promise { - const metadata = await this.getMetadata() - if (!metadata) return + const metadata = await this.getMetadata(); + if (!metadata) return; // Future migrations will be added here // if (metadata.version < 2) { ... } @@ -639,29 +606,29 @@ export class WalletStorageService { /** 从 localStorage 迁移旧数据 */ async migrateFromLocalStorage(): Promise { - this.ensureInitialized() + this.ensureInitialized(); - const oldData = localStorage.getItem('bfm_wallets') - if (!oldData) return false + const oldData = localStorage.getItem('bfm_wallets'); + if (!oldData) return false; try { const { wallets, currentWalletId } = JSON.parse(oldData) as { wallets: Array<{ - id: string - name: string - keyType?: string - address: string - chain: string - encryptedMnemonic?: unknown - createdAt: number + id: string; + name: string; + keyType?: string; + address: string; + chain: string; + encryptedMnemonic?: unknown; + createdAt: number; chainAddresses?: Array<{ - chain: string - address: string - tokens?: unknown[] - }> - }> - currentWalletId: string | null - } + chain: string; + address: string; + tokens?: unknown[]; + }>; + }>; + currentWalletId: string | null; + }; // Migrate wallets for (const oldWallet of wallets) { @@ -675,13 +642,13 @@ export class WalletStorageService { isBackedUp: false, createdAt: oldWallet.createdAt, updatedAt: Date.now(), - } - await this.saveWallet(newWallet) + }; + await this.saveWallet(newWallet); // Migrate chain addresses if (oldWallet.chainAddresses) { for (const oldAddr of oldWallet.chainAddresses) { - const addressKey = `${oldWallet.id}:${oldAddr.chain}` + const addressKey = `${oldWallet.id}:${oldAddr.chain}`; const newAddr: ChainAddressInfo = { addressKey, walletId: oldWallet.id, @@ -690,14 +657,14 @@ export class WalletStorageService { assets: [], isCustomAssets: false, isFrozen: false, - } - await this.saveChainAddress(newAddr) + }; + await this.saveChainAddress(newAddr); } } } // Update walleter info - const existingWalleter = await this.getWalleterInfo() + const existingWalleter = await this.getWalleterInfo(); if (!existingWalleter) { await this.saveWalleterInfo({ name: 'User', @@ -707,29 +674,29 @@ export class WalletStorageService { agreementAccepted: true, createdAt: Date.now(), updatedAt: Date.now(), - }) + }); } else if (currentWalletId) { await this.saveWalleterInfo({ ...existingWalleter, activeWalletId: currentWalletId, updatedAt: Date.now(), - }) + }); } // Remove old data after successful migration - localStorage.removeItem('bfm_wallets') + localStorage.removeItem('bfm_wallets'); - return true + return true; } catch (err) { - console.error('Failed to migrate from localStorage:', err) + console.error('Failed to migrate from localStorage:', err); throw new WalletStorageError( WalletStorageErrorCode.MIGRATION_FAILED, 'Failed to migrate data from localStorage', - err instanceof Error ? err : undefined - ) + err instanceof Error ? err : undefined, + ); } } } /** 单例实例 */ -export const walletStorageService = new WalletStorageService() +export const walletStorageService = new WalletStorageService(); diff --git a/src/services/wallet-storage/types.ts b/src/services/wallet-storage/types.ts index 156c0f02..42ffe12e 100644 --- a/src/services/wallet-storage/types.ts +++ b/src/services/wallet-storage/types.ts @@ -1,6 +1,6 @@ import type { EncryptedData } from '@/lib/crypto' -export const WALLET_STORAGE_VERSION = 1 +export const WALLET_STORAGE_VERSION = 2 /** 钱包用户信息 */ export interface WalleterInfo { @@ -142,3 +142,17 @@ export class WalletStorageError extends Error { this.name = 'WalletStorageError' } } + +/** 钱包存储版本不兼容错误 */ +export class WalletStorageMigrationError extends Error { + readonly code = 'WALLET_MIGRATION_REQUIRED' + readonly storedVersion: number + readonly requiredVersion: number + + constructor(storedVersion: number, requiredVersion: number) { + super(`Wallet storage migration required: v${storedVersion} → v${requiredVersion}`) + this.name = 'WalletStorageMigrationError' + this.storedVersion = storedVersion + this.requiredVersion = requiredVersion + } +} diff --git a/src/stores/hooks.ts b/src/stores/hooks.ts index 092fd271..2c009041 100644 --- a/src/stores/hooks.ts +++ b/src/stores/hooks.ts @@ -65,3 +65,8 @@ export function useWalletLoading() { export function useWalletInitialized() { return useStore(walletStore, (state) => state.isInitialized) } + +/** 钱包存储是否需要迁移 */ +export function useWalletMigrationRequired() { + return useStore(walletStore, (state) => state.migrationRequired) +} diff --git a/src/stores/index.ts b/src/stores/index.ts index 302167b3..4fb388da 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -51,4 +51,5 @@ export { useHasWallet, useWalletLoading, useWalletInitialized, + useWalletMigrationRequired, } from './hooks' diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index a4d724b0..cb0477e3 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -5,6 +5,7 @@ import { walletStorageService, type WalletInfo, type ChainAddressInfo, + WalletStorageMigrationError, } from '@/services/wallet-storage' /** @@ -89,6 +90,8 @@ export interface WalletState { chainPreferences: Record isLoading: boolean isInitialized: boolean + /** 需要迁移数据库 */ + migrationRequired: boolean } // localStorage key for chain preferences @@ -124,6 +127,7 @@ const initialState: WalletState = { chainPreferences: {}, isLoading: false, isInitialized: false, + migrationRequired: false, } // 创建 Store @@ -230,6 +234,17 @@ export const walletActions = { isLoading: false, })) } catch (error) { + // 检测版本不兼容错误 + if (error instanceof WalletStorageMigrationError) { + walletStore.setState((state) => ({ + ...state, + isInitialized: true, + isLoading: false, + migrationRequired: true, + })) + return + } + console.error('Failed to initialize wallets:', error) walletStore.setState((state) => ({ ...state, From 5942ce3b519afdfa8324ecd7c88187047c402475 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:22:58 +0800 Subject: [PATCH 09/27] =?UTF-8?q?fix:=20MigrationRequiredView=20=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=B7=B3=E8=BD=AC=E5=88=B0=20clear.html=20=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由于组件在 Stackflow context 外渲染,无法使用路由导航 --- .../common/migration-required-view.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/common/migration-required-view.tsx b/src/components/common/migration-required-view.tsx index 5697dd0c..fa8cf7ce 100644 --- a/src/components/common/migration-required-view.tsx +++ b/src/components/common/migration-required-view.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { IconAlertTriangle, IconDatabase } from '@tabler/icons-react' +import { IconAlertTriangle, IconTrash } from '@tabler/icons-react' import { Button } from '@/components/ui/button' /** @@ -10,10 +11,13 @@ import { Button } from '@/components/ui/button' */ export function MigrationRequiredView() { const { t } = useTranslation(['settings', 'common']) + const [isClearing, setIsClearing] = useState(false) - const handleGoToStorage = () => { - // 直接导航到存储管理页面(不依赖 Stackflow) - window.location.href = '/#/settings/storage' + const handleClearData = () => { + setIsClearing(true) + // 跳转到 clear.html 进行清理 + const baseUri = import.meta.env.BASE_URL || '/' + window.location.href = `${baseUri}clear.html` } return ( @@ -35,9 +39,23 @@ export function MigrationRequiredView() {

-
From 9bd5804012354a393bba786d98f598d6b5a727ab Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:25:38 +0800 Subject: [PATCH 10/27] =?UTF-8?q?fix:=20=E5=8C=BA=E5=88=86=E5=85=A8?= =?UTF-8?q?=E6=96=B0=E5=AE=89=E8=A3=85=E5=92=8C=E6=97=A7=E7=89=88=E5=8D=87?= =?UTF-8?q?=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 链配置版本检测:只有当 storedVersion 为 null 且有存储数据时才触发迁移 全新安装(无存储数据)不触发迁移 --- src/services/chain-config/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index e9b5481e..42a30999 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -280,9 +280,10 @@ export async function initialize(): Promise { loadDefaultVersion(), ]) - // 检测旧版数据:storedVersion 为 null 且 bundledVersion >= 2.0.0 + // 检测旧版数据:storedVersion 为 null 且有存储的配置数据(说明是旧版升级) const bundledMajor = parseMajorFromSemver(bundledVersion) - if (storedDefaultVersion === null && bundledMajor >= 2) { + const hasStoredData = storedConfigs.length > 0 || enabledMap !== null || subscription !== null + if (storedDefaultVersion === null && bundledMajor >= 2 && hasStoredData) { throw new ChainConfigMigrationError(storedDefaultVersion, bundledVersion) } From f0467bd60c882cbc3089b9b5b5d3e8d141c0a058 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:27:20 +0800 Subject: [PATCH 11/27] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20hasStoredData?= =?UTF-8?q?=20=E6=A3=80=E6=9F=A5=20-=20enabledMap=20=E6=98=AF=E7=A9=BA?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E8=80=8C=E9=9D=9E=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/chain-config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index 42a30999..4e476f5f 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -282,7 +282,7 @@ export async function initialize(): Promise { // 检测旧版数据:storedVersion 为 null 且有存储的配置数据(说明是旧版升级) const bundledMajor = parseMajorFromSemver(bundledVersion) - const hasStoredData = storedConfigs.length > 0 || enabledMap !== null || subscription !== null + const hasStoredData = storedConfigs.length > 0 || Object.keys(enabledMap).length > 0 || subscription !== null if (storedDefaultVersion === null && bundledMajor >= 2 && hasStoredData) { throw new ChainConfigMigrationError(storedDefaultVersion, bundledVersion) } From e9834cbc64162ca96fe786838a10c4d688d7e807 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:42:35 +0800 Subject: [PATCH 12/27] =?UTF-8?q?feat(bioforest):=20=E4=BD=BF=E7=94=A8=20Z?= =?UTF-8?q?od=20Schema=20=E9=AA=8C=E8=AF=81=20API=20=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 schema.ts 定义 AddressAssetsResponseSchema - asset-service.ts 使用 safeParse 验证 API 响应 - 新增单元测试验证 Schema 正确性 - 无效数据返回空余额而非崩溃 --- .../bioforest/asset-service.test.ts | 124 ++++++++++++++++++ .../chain-adapter/bioforest/asset-service.ts | 81 ++++-------- .../chain-adapter/bioforest/schema.ts | 37 ++++++ 3 files changed, 183 insertions(+), 59 deletions(-) create mode 100644 src/services/chain-adapter/bioforest/asset-service.test.ts create mode 100644 src/services/chain-adapter/bioforest/schema.ts diff --git a/src/services/chain-adapter/bioforest/asset-service.test.ts b/src/services/chain-adapter/bioforest/asset-service.test.ts new file mode 100644 index 00000000..97702063 --- /dev/null +++ b/src/services/chain-adapter/bioforest/asset-service.test.ts @@ -0,0 +1,124 @@ +/** + * BioforestAssetService 单元测试 + */ + +import { describe, it, expect } from 'vitest' +import { AddressAssetsResponseSchema } from './schema' + +describe('AddressAssetsResponseSchema', () => { + it('should parse successful response with assets', () => { + const response = { + success: true, + result: { + address: 'b9gB9NzHKWsDKGYFCaNva6xRnxPwFfGcfx', + assets: { + LLLQL: { + BFM: { + sourceChainMagic: 'LLLQL', + assetType: 'BFM', + sourceChainName: 'bfmeta', + assetNumber: '998936', + iconUrl: 'https://example.com/icon.png', + }, + }, + }, + forgingRewards: '0', + }, + } + + const parsed = AddressAssetsResponseSchema.safeParse(response) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.success).toBe(true) + expect(parsed.data.result?.assets.LLLQL?.BFM?.assetType).toBe('BFM') + expect(parsed.data.result?.assets.LLLQL?.BFM?.assetNumber).toBe('998936') + } + }) + + it('should parse error response', () => { + const response = { + success: false, + error: { + code: 500, + message: 'input address is wrong', + info: 'Error: input address is wrong', + }, + } + + const parsed = AddressAssetsResponseSchema.safeParse(response) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.success).toBe(false) + expect(parsed.data.error?.code).toBe(500) + } + }) + + it('should reject response with missing required asset fields', () => { + const response = { + success: true, + result: { + address: 'test', + assets: { + LLLQL: { + BFM: { + // missing assetType - required field + assetNumber: '100', + sourceChainMagic: 'LLLQL', + sourceChainName: 'bfmeta', + }, + }, + }, + }, + } + + const parsed = AddressAssetsResponseSchema.safeParse(response) + expect(parsed.success).toBe(false) + }) + + it('should reject response with empty assetType', () => { + const response = { + success: true, + result: { + address: 'test', + assets: { + LLLQL: { + BFM: { + assetType: '', // empty string - should fail min(1) + assetNumber: '100', + sourceChainMagic: 'LLLQL', + sourceChainName: 'bfmeta', + }, + }, + }, + }, + } + + const parsed = AddressAssetsResponseSchema.safeParse(response) + expect(parsed.success).toBe(false) + }) +}) + +describe.skipIf(!process.env.TEST_REAL_API)('BioforestAssetService Integration', () => { + const API_URL = 'https://walletapi.bfmeta.info' + const CHAIN_PATH = 'bfm' + const TEST_ADDRESS = 'b9gB9NzHKWsDKGYFCaNva6xRnxPwFfGcfx' + + it('should parse real API response', async () => { + const response = await fetch(`${API_URL}/wallet/${CHAIN_PATH}/address/asset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: TEST_ADDRESS }), + }) + + const json: unknown = await response.json() + const parsed = AddressAssetsResponseSchema.safeParse(json) + + expect(parsed.success).toBe(true) + if (parsed.success && parsed.data.success && parsed.data.result) { + expect(parsed.data.result.address).toBe(TEST_ADDRESS) + // Should have at least native token + const magicKeys = Object.keys(parsed.data.result.assets) + expect(magicKeys.length).toBeGreaterThan(0) + } + }) +}) diff --git a/src/services/chain-adapter/bioforest/asset-service.ts b/src/services/chain-adapter/bioforest/asset-service.ts index 5a74324a..4989b51e 100644 --- a/src/services/chain-adapter/bioforest/asset-service.ts +++ b/src/services/chain-adapter/bioforest/asset-service.ts @@ -8,28 +8,7 @@ import type { ChainConfig } from '@/services/chain-config' import { Amount } from '@/types/amount' import type { IAssetService, Address, Balance, TokenMetadata } from '../types' import { ChainServiceError, ChainErrorCodes } from '../types' - -/** - * mpay API response format for getAddressAssets - * POST /wallet/{chainId}/address/asset - */ -interface BioforestAddressAssetsResponse { - success: boolean - result?: { - address: string - assets: { - [magic: string]: { - [assetType: string]: { - assetNumber: string - assetType: string - sourceChainMagic: string - sourceChainName: string - iconUrl?: string - } - } - } - } -} +import { AddressAssetsResponseSchema } from './schema' export class BioforestAssetService implements IAssetService { private readonly config: ChainConfig @@ -43,18 +22,19 @@ export class BioforestAssetService implements IAssetService { this.apiPath = config.api?.path ?? config.id } - async getNativeBalance(address: Address): Promise { - const balances = await this.getTokenBalances(address) - const native = balances.find((b) => b.symbol === this.config.symbol) - - if (native) return native - + private getEmptyNativeBalance(): Balance { return { amount: Amount.zero(this.config.decimals, this.config.symbol), symbol: this.config.symbol, } } + async getNativeBalance(address: Address): Promise { + const balances = await this.getTokenBalances(address) + const native = balances.find((b) => b.symbol === this.config.symbol) + return native ?? this.getEmptyNativeBalance() + } + async getTokenBalance(address: Address, tokenAddress: Address): Promise { // In BioForest, tokenAddress is actually assetType const assetType = tokenAddress @@ -71,17 +51,10 @@ export class BioforestAssetService implements IAssetService { async getTokenBalances(address: Address): Promise { if (!this.apiUrl) { - // No RPC URL configured, return empty balance - return [ - { - amount: Amount.zero(this.config.decimals, this.config.symbol), - symbol: this.config.symbol, - }, - ] + return [this.getEmptyNativeBalance()] } try { - // mpay API: POST /wallet/{chainApiPath}/address/asset const response = await fetch(`${this.apiUrl}/wallet/${this.apiPath}/address/asset`, { method: 'POST', headers: { @@ -98,21 +71,22 @@ export class BioforestAssetService implements IAssetService { ) } - const json = (await response.json()) as BioforestAddressAssetsResponse + const json: unknown = await response.json() + const parsed = AddressAssetsResponseSchema.safeParse(json) + + if (!parsed.success) { + console.warn('[BioforestAssetService] Invalid API response:', parsed.error.message) + return [this.getEmptyNativeBalance()] + } - if (!json.success || !json.result) { - // API returned success=false or no result, return empty - return [ - { - amount: Amount.zero(this.config.decimals, this.config.symbol), - symbol: this.config.symbol, - }, - ] + const { success, result } = parsed.data + if (!success || !result) { + return [this.getEmptyNativeBalance()] } - // Parse mpay response format: assets[magic][assetType].assetNumber + // Parse response: assets[magic][assetType].assetNumber const balances: Balance[] = [] - const { assets } = json.result + const { assets } = result for (const magic of Object.keys(assets)) { const magicAssets = assets[magic] @@ -122,7 +96,6 @@ export class BioforestAssetService implements IAssetService { const asset = magicAssets[assetType] if (!asset) continue - // BioForest chains use fixed 8 decimals const decimals = this.config.decimals const amount = Amount.fromRaw(asset.assetNumber, decimals, asset.assetType) @@ -133,17 +106,7 @@ export class BioforestAssetService implements IAssetService { } } - // If no balances found, return zero balance for native token - if (balances.length === 0) { - return [ - { - amount: Amount.zero(this.config.decimals, this.config.symbol), - symbol: this.config.symbol, - }, - ] - } - - return balances + return balances.length > 0 ? balances : [this.getEmptyNativeBalance()] } catch (error) { if (error instanceof ChainServiceError) throw error throw new ChainServiceError( diff --git a/src/services/chain-adapter/bioforest/schema.ts b/src/services/chain-adapter/bioforest/schema.ts new file mode 100644 index 00000000..cf8aa9f6 --- /dev/null +++ b/src/services/chain-adapter/bioforest/schema.ts @@ -0,0 +1,37 @@ +/** + * BioForest API Response Schemas + * + * 用于验证外部 API 返回的数据 + */ + +import { z } from 'zod' + +/** 单个资产信息 */ +const AssetInfoSchema = z.object({ + assetNumber: z.string(), + assetType: z.string().min(1), + sourceChainMagic: z.string(), + sourceChainName: z.string(), + iconUrl: z.string().optional(), +}) + +/** POST /wallet/{chainId}/address/asset 响应 */ +export const AddressAssetsResponseSchema = z.object({ + success: z.boolean(), + result: z + .object({ + address: z.string(), + assets: z.record(z.string(), z.record(z.string(), AssetInfoSchema)), + forgingRewards: z.string().optional(), + }) + .optional(), + error: z + .object({ + code: z.number(), + message: z.string(), + info: z.string().optional(), + }) + .optional(), +}) + +export type AddressAssetsResponse = z.infer From 944c4063d3ee8e2b780bac1d8d71293021e562b5 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:53:25 +0800 Subject: [PATCH 13/27] =?UTF-8?q?fix(bioforest):=20=E4=BF=AE=E5=A4=8D=20As?= =?UTF-8?q?setService=20=E6=9E=84=E9=80=A0=E5=87=BD=E6=95=B0=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 构造函数改为接收 chainId: string - 使用 chainConfigService.get() 获取类型安全的 ChainConfig - 添加单元测试验证 symbol 不为 undefined - 遵循 unknown → safe-type 边界验证原则 --- .../bioforest/asset-service.test.ts | 55 ++++++++++++++++++- .../chain-adapter/bioforest/asset-service.ts | 55 ++++++++++++------- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/src/services/chain-adapter/bioforest/asset-service.test.ts b/src/services/chain-adapter/bioforest/asset-service.test.ts index 97702063..2e6d70b2 100644 --- a/src/services/chain-adapter/bioforest/asset-service.test.ts +++ b/src/services/chain-adapter/bioforest/asset-service.test.ts @@ -2,8 +2,31 @@ * BioforestAssetService 单元测试 */ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { AddressAssetsResponseSchema } from './schema' +import { BioforestAssetService } from './asset-service' + +// Mock chainConfigService +vi.mock('@/services/chain-config', () => ({ + chainConfigService: { + get: vi.fn((chainId: string) => { + if (chainId === 'bfmeta') { + return { + id: 'bfmeta', + type: 'bioforest', + name: 'BFMeta', + symbol: 'BFM', + decimals: 8, + api: { + url: 'https://walletapi.bfmeta.info', + path: 'bfm', + }, + } + } + return null + }), + }, +})) describe('AddressAssetsResponseSchema', () => { it('should parse successful response with assets', () => { @@ -98,6 +121,36 @@ describe('AddressAssetsResponseSchema', () => { }) }) +describe('BioforestAssetService', () => { + let service: BioforestAssetService + + beforeEach(() => { + service = new BioforestAssetService('bfmeta') + }) + + it('should get config from chainConfigService', () => { + // getEmptyNativeBalance uses getConfig internally + const balance = (service as any).getEmptyNativeBalance() + + expect(balance.symbol).toBe('BFM') + expect(balance.amount.decimals).toBe(8) + }) + + it('should throw error for unknown chainId', () => { + const unknownService = new BioforestAssetService('unknown-chain') + + expect(() => (unknownService as any).getConfig()).toThrow('Chain config not found: unknown-chain') + }) + + it('should return balance with valid symbol from getEmptyNativeBalance', () => { + const balance = (service as any).getEmptyNativeBalance() + + expect(balance.symbol).toBe('BFM') + expect(balance.symbol).not.toBeUndefined() + expect(balance.amount.symbol).toBe('BFM') + }) +}) + describe.skipIf(!process.env.TEST_REAL_API)('BioforestAssetService Integration', () => { const API_URL = 'https://walletapi.bfmeta.info' const CHAIN_PATH = 'bfm' diff --git a/src/services/chain-adapter/bioforest/asset-service.ts b/src/services/chain-adapter/bioforest/asset-service.ts index 4989b51e..6dc11075 100644 --- a/src/services/chain-adapter/bioforest/asset-service.ts +++ b/src/services/chain-adapter/bioforest/asset-service.ts @@ -4,34 +4,46 @@ * Migrated from mpay: libs/wallet-base/services/wallet/chain-base/bioforest-chain.base.ts */ -import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService, type ChainConfig } from '@/services/chain-config' import { Amount } from '@/types/amount' import type { IAssetService, Address, Balance, TokenMetadata } from '../types' import { ChainServiceError, ChainErrorCodes } from '../types' import { AddressAssetsResponseSchema } from './schema' export class BioforestAssetService implements IAssetService { - private readonly config: ChainConfig - private readonly apiUrl: string - private readonly apiPath: string - - constructor(config: ChainConfig) { - this.config = config - // 使用提供商配置(外部依赖) - this.apiUrl = config.api?.url ?? '' - this.apiPath = config.api?.path ?? config.id + private readonly chainId: string + private config: ChainConfig | null = null + + constructor(chainId: string) { + this.chainId = chainId + } + + private getConfig(): ChainConfig { + if (!this.config) { + const config = chainConfigService.get(this.chainId) + if (!config) { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_FOUND, + `Chain config not found: ${this.chainId}`, + ) + } + this.config = config + } + return this.config } private getEmptyNativeBalance(): Balance { + const config = this.getConfig() return { - amount: Amount.zero(this.config.decimals, this.config.symbol), - symbol: this.config.symbol, + amount: Amount.zero(config.decimals, config.symbol), + symbol: config.symbol, } } async getNativeBalance(address: Address): Promise { const balances = await this.getTokenBalances(address) - const native = balances.find((b) => b.symbol === this.config.symbol) + const config = this.getConfig() + const native = balances.find((b) => b.symbol === config.symbol) return native ?? this.getEmptyNativeBalance() } @@ -43,19 +55,24 @@ export class BioforestAssetService implements IAssetService { if (token) return token + const config = this.getConfig() return { - amount: Amount.zero(this.config.decimals, assetType), + amount: Amount.zero(config.decimals, assetType), symbol: assetType, } } async getTokenBalances(address: Address): Promise { - if (!this.apiUrl) { + const config = this.getConfig() + const apiUrl = config.api?.url ?? '' + const apiPath = config.api?.path ?? config.id + + if (!apiUrl) { return [this.getEmptyNativeBalance()] } try { - const response = await fetch(`${this.apiUrl}/wallet/${this.apiPath}/address/asset`, { + const response = await fetch(`${apiUrl}/wallet/${apiPath}/address/asset`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -96,7 +113,7 @@ export class BioforestAssetService implements IAssetService { const asset = magicAssets[assetType] if (!asset) continue - const decimals = this.config.decimals + const decimals = config.decimals const amount = Amount.fromRaw(asset.assetNumber, decimals, asset.assetType) balances.push({ @@ -119,12 +136,12 @@ export class BioforestAssetService implements IAssetService { } async getTokenMetadata(tokenAddress: Address): Promise { - // In BioForest, tokenAddress is assetType + const config = this.getConfig() return { address: null, name: tokenAddress, symbol: tokenAddress, - decimals: this.config.decimals, + decimals: config.decimals, } } } From 6932e445226dd70c86ad5c7fe811e6c22e32bfa6 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 19:56:41 +0800 Subject: [PATCH 14/27] =?UTF-8?q?fix:=20=E5=AF=BC=E5=87=BA=20chainConfigSe?= =?UTF-8?q?rvice=20=E5=B9=B6=E4=BF=AE=E6=AD=A3=E6=96=B9=E6=B3=95=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chain-config/index.ts 导出 chainConfigService - asset-service.ts 使用 getConfig() 而非 get() - 更新测试 mock --- src/services/chain-adapter/bioforest/asset-service.test.ts | 2 +- src/services/chain-adapter/bioforest/asset-service.ts | 2 +- src/services/chain-config/index.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/chain-adapter/bioforest/asset-service.test.ts b/src/services/chain-adapter/bioforest/asset-service.test.ts index 2e6d70b2..946a7e53 100644 --- a/src/services/chain-adapter/bioforest/asset-service.test.ts +++ b/src/services/chain-adapter/bioforest/asset-service.test.ts @@ -9,7 +9,7 @@ import { BioforestAssetService } from './asset-service' // Mock chainConfigService vi.mock('@/services/chain-config', () => ({ chainConfigService: { - get: vi.fn((chainId: string) => { + getConfig: vi.fn((chainId: string) => { if (chainId === 'bfmeta') { return { id: 'bfmeta', diff --git a/src/services/chain-adapter/bioforest/asset-service.ts b/src/services/chain-adapter/bioforest/asset-service.ts index 6dc11075..bd23655e 100644 --- a/src/services/chain-adapter/bioforest/asset-service.ts +++ b/src/services/chain-adapter/bioforest/asset-service.ts @@ -20,7 +20,7 @@ export class BioforestAssetService implements IAssetService { private getConfig(): ChainConfig { if (!this.config) { - const config = chainConfigService.get(this.chainId) + const config = chainConfigService.getConfig(this.chainId) if (!config) { throw new ChainServiceError( ChainErrorCodes.CHAIN_NOT_FOUND, diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index 4e476f5f..01043733 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -1,4 +1,5 @@ export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType } from './types' +export { chainConfigService } from './service' import { ChainConfigListSchema, ChainConfigSchema, ChainConfigSubscriptionSchema, VersionedChainConfigFileSchema } from './schema' import { fetchSubscription, type FetchSubscriptionResult } from './subscription' From 5e34f92be09e94093937ba2e8107c168b0058a2a Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 20:04:28 +0800 Subject: [PATCH 15/27] =?UTF-8?q?feat:=20=E5=9C=B0=E5=9D=80=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E6=9F=A5=E8=AF=A2=E4=BD=BF=E7=94=A8=E5=86=85=E9=83=A8?= =?UTF-8?q?=20chainProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 #163 地址查询功能 - 新增 useAddressTransactionsQuery hook - AddressTransactionsPage 使用 adapter.transaction.getTransactionHistory - 保留浏览器链接作为补充查询方式 - 添加交易列表展示组件 --- src/i18n/locales/en/common.json | 2 + src/i18n/locales/zh-CN/common.json | 2 + src/pages/address-transactions/index.tsx | 161 +++++++++++------- src/queries/index.ts | 5 + src/queries/use-address-transactions-query.ts | 42 +++++ 5 files changed, 152 insertions(+), 60 deletions(-) create mode 100644 src/queries/use-address-transactions-query.ts diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 75fcc4b2..d9cb3c6a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -363,6 +363,8 @@ "addressOrHashPlaceholder": "Enter address or tx hash", "otherChains": "Other Chains", "error": "Query Failed", + "queryError": "Failed to query transactions, please try again", + "noTransactions": "No transactions found", "onChain": "on {{chain}}", "explorerHint": "Transaction history requires block explorer", "openExplorer": "Open {{name}} Explorer", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index df7a0ed9..662c1ba1 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -341,6 +341,8 @@ "addressOrHashPlaceholder": "输入地址或交易哈希", "otherChains": "其他链", "error": "查询失败", + "queryError": "查询交易失败,请稍后重试", + "noTransactions": "暂无交易记录", "onChain": "在 {{chain}} 上", "explorerHint": "交易记录需要通过区块浏览器查询", "openExplorer": "打开 {{name}} 浏览器", diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx index 1c8c1b8f..9e02d58b 100644 --- a/src/pages/address-transactions/index.tsx +++ b/src/pages/address-transactions/index.tsx @@ -8,56 +8,92 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Card, CardContent } from '@/components/ui/card' import { useEnabledChains } from '@/stores' -import { IconSearch, IconExternalLink, IconInfoCircle } from '@tabler/icons-react' +import { useAddressTransactionsQuery } from '@/queries' +import { IconSearch, IconExternalLink, IconArrowUpRight, IconArrowDownLeft, IconLoader2 } from '@tabler/icons-react' +import type { Transaction } from '@/services/chain-adapter/types' + +function formatAmount(amount: string, decimals: number): string { + const num = parseFloat(amount) / Math.pow(10, decimals) + return num.toLocaleString(undefined, { maximumFractionDigits: 6 }) +} + +function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleString() +} + +function TransactionItem({ tx, address }: { tx: Transaction; address: string }) { + const isOutgoing = tx.from.toLowerCase() === address.toLowerCase() + + return ( +
+
+ {isOutgoing ? : } +
+
+
+ {isOutgoing ? `To: ${tx.to}` : `From: ${tx.from}`} +
+
+ {formatDate(tx.timestamp)} +
+
+
+ {isOutgoing ? '-' : '+'}{formatAmount(tx.value, 8)} {tx.symbol} +
+
+ ) +} export function AddressTransactionsPage() { const { t } = useTranslation(['common', 'wallet']) const { goBack } = useNavigation() const enabledChains = useEnabledChains() - const [selectedChain, setSelectedChain] = useState('ethereum') + const [selectedChain, setSelectedChain] = useState('bfmeta') const [address, setAddress] = useState('') + const [searchAddress, setSearchAddress] = useState('') const selectedChainConfig = useMemo( () => enabledChains.find((c) => c.id === selectedChain), [enabledChains, selectedChain] ) - const explorerUrl = useMemo(() => { - if (!selectedChainConfig?.explorer || !address.trim()) return null + const { data: transactions, isLoading, isError, refetch } = useAddressTransactionsQuery({ + chainId: selectedChain, + address: searchAddress, + enabled: !!searchAddress, + }) + const explorerUrl = useMemo(() => { + if (!selectedChainConfig?.explorer || !searchAddress.trim()) return null const { queryAddress, url } = selectedChainConfig.explorer if (queryAddress) { - return queryAddress.replace(':address', address.trim()) - } - // Fallback: common explorer patterns - if (url.includes('etherscan')) { - return `${url}/address/${address.trim()}` - } - if (url.includes('bscscan')) { - return `${url}/address/${address.trim()}` + return queryAddress.replace(':address', searchAddress.trim()) } - if (url.includes('tronscan')) { - return `${url}/#/address/${address.trim()}` - } - return `${url}/address/${address.trim()}` - }, [selectedChainConfig, address]) + return `${url}/address/${searchAddress.trim()}` + }, [selectedChainConfig, searchAddress]) - const handleOpenExplorer = useCallback(() => { - if (explorerUrl) { - window.open(explorerUrl, '_blank', 'noopener,noreferrer') + const handleSearch = useCallback(() => { + if (address.trim()) { + setSearchAddress(address.trim()) } - }, [explorerUrl]) + }, [address]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && explorerUrl) { - handleOpenExplorer() + if (e.key === 'Enter') { + handleSearch() } }, - [explorerUrl, handleOpenExplorer] + [handleSearch] ) + const handleOpenExplorer = useCallback(() => { + if (explorerUrl) { + window.open(explorerUrl, '_blank', 'noopener,noreferrer') + } + }, [explorerUrl]) + const evmChains = enabledChains.filter((c) => c.type === 'evm') const otherChains = enabledChains.filter((c) => c.type !== 'evm') @@ -102,60 +138,65 @@ export function AddressTransactionsPage() { {/* Address Input */}
- +
setAddress(e.target.value)} onKeyDown={handleKeyDown} className="flex-1 font-mono text-sm" /> -
- {/* Info Card */} - - -
- -
-

- {t('common:addressLookup.explorerHint')} -

- {selectedChainConfig?.explorer?.url && ( + {/* Results */} + {searchAddress && ( + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ {t('common:addressLookup.queryError')} +
+ ) : transactions && transactions.length > 0 ? ( +
+ {transactions.map((tx) => ( + + ))} +
+ ) : ( +
+ {t('common:addressLookup.noTransactions')} +
+ )} + + {/* Explorer Link */} + {explorerUrl && ( +
- )} -
-
- - - - {/* Quick Links */} - {address.trim() && explorerUrl && ( - - - +
+ )}
)} diff --git a/src/queries/index.ts b/src/queries/index.ts index 458077d4..14edcc29 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -64,3 +64,8 @@ export { addressBalanceKeys, type AddressBalanceResult, } from './use-address-balance-query' + +export { + useAddressTransactionsQuery, + addressTransactionsQueryKeys, +} from './use-address-transactions-query' diff --git a/src/queries/use-address-transactions-query.ts b/src/queries/use-address-transactions-query.ts new file mode 100644 index 00000000..31235ae9 --- /dev/null +++ b/src/queries/use-address-transactions-query.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query' +import { getAdapterRegistry } from '@/services/chain-adapter' +import type { Transaction } from '@/services/chain-adapter/types' + +export const addressTransactionsQueryKeys = { + all: ['addressTransactions'] as const, + address: (chainId: string, address: string) => ['addressTransactions', chainId, address] as const, +} + +interface UseAddressTransactionsQueryOptions { + chainId: string + address: string + limit?: number + enabled?: boolean +} + +export function useAddressTransactionsQuery({ + chainId, + address, + limit = 20, + enabled = true, +}: UseAddressTransactionsQueryOptions) { + return useQuery({ + queryKey: addressTransactionsQueryKeys.address(chainId, address), + queryFn: async (): Promise => { + if (!chainId || !address) return [] + + const registry = getAdapterRegistry() + const adapter = registry.getAdapter(chainId) + + if (!adapter) { + console.warn(`[useAddressTransactionsQuery] No adapter for chain: ${chainId}`) + return [] + } + + return adapter.transaction.getTransactionHistory(address, limit) + }, + enabled: enabled && !!chainId && !!address.trim(), + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + }) +} From 1f8edd4a7c21310b601c597719dfb16b76a20cbb Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 20:07:54 +0800 Subject: [PATCH 16/27] =?UTF-8?q?fix:=20useAddressBalanceQuery=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20registerChainConfigs=20=E6=9B=BF=E4=BB=A3=E5=B7=B2?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E7=9A=84=20setChainConfigs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/queries/use-address-balance-query.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/queries/use-address-balance-query.ts b/src/queries/use-address-balance-query.ts index cc87c903..42f55fd7 100644 --- a/src/queries/use-address-balance-query.ts +++ b/src/queries/use-address-balance-query.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { getAdapterRegistry, setupAdapters } from '@/services/chain-adapter' +import { getAdapterRegistry, setupAdapters, registerChainConfigs } from '@/services/chain-adapter' import { chainConfigStore, chainConfigSelectors } from '@/stores' import type { Balance } from '@/services/chain-adapter/types' @@ -41,9 +41,10 @@ export function useAddressBalanceQuery(chainId: string, address: string, enabled return { balance: null, error: `Unknown chain: ${chainId}` } } - const registry = getAdapterRegistry() - registry.setChainConfigs([chainConfig]) + // 确保链已注册到 registry + registerChainConfigs([chainConfig]) + const registry = getAdapterRegistry() const adapter = registry.getAdapter(chainId) if (!adapter) { return { balance: null, error: `No adapter for chain: ${chainId}` } From 49f683fbdfe0bad0c077ebe6f6dc0acc8c24fea2 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 20:08:44 +0800 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20useAddressTransactionsQuery=20?= =?UTF-8?q?=E5=90=8C=E6=A0=B7=E9=9C=80=E8=A6=81=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=20adapter=20=E5=92=8C=E6=B3=A8=E5=86=8C=E9=93=BE=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/queries/use-address-transactions-query.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/queries/use-address-transactions-query.ts b/src/queries/use-address-transactions-query.ts index 31235ae9..4c79b711 100644 --- a/src/queries/use-address-transactions-query.ts +++ b/src/queries/use-address-transactions-query.ts @@ -1,7 +1,16 @@ import { useQuery } from '@tanstack/react-query' -import { getAdapterRegistry } from '@/services/chain-adapter' +import { getAdapterRegistry, setupAdapters, registerChainConfigs } from '@/services/chain-adapter' +import { chainConfigStore, chainConfigSelectors } from '@/stores' import type { Transaction } from '@/services/chain-adapter/types' +let adaptersInitialized = false +function ensureAdapters() { + if (!adaptersInitialized) { + setupAdapters() + adaptersInitialized = true + } +} + export const addressTransactionsQueryKeys = { all: ['addressTransactions'] as const, address: (chainId: string, address: string) => ['addressTransactions', chainId, address] as const, @@ -25,6 +34,18 @@ export function useAddressTransactionsQuery({ queryFn: async (): Promise => { if (!chainId || !address) return [] + ensureAdapters() + + const state = chainConfigStore.state + const chainConfig = chainConfigSelectors.getChainById(state, chainId) + if (!chainConfig) { + console.warn(`[useAddressTransactionsQuery] Unknown chain: ${chainId}`) + return [] + } + + // 确保链已注册到 registry + registerChainConfigs([chainConfig]) + const registry = getAdapterRegistry() const adapter = registry.getAdapter(chainId) From 1bbf90101dc451cbb35b0373b2c99c3d48342db8 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 20:12:43 +0800 Subject: [PATCH 18/27] =?UTF-8?q?fix:=20Schema=20=E4=BD=BF=E7=94=A8=20null?= =?UTF-8?q?ish()=20=E5=85=81=E8=AE=B8=20result=20=E4=B8=BA=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/chain-adapter/bioforest/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/chain-adapter/bioforest/schema.ts b/src/services/chain-adapter/bioforest/schema.ts index cf8aa9f6..8768a286 100644 --- a/src/services/chain-adapter/bioforest/schema.ts +++ b/src/services/chain-adapter/bioforest/schema.ts @@ -24,14 +24,14 @@ export const AddressAssetsResponseSchema = z.object({ assets: z.record(z.string(), z.record(z.string(), AssetInfoSchema)), forgingRewards: z.string().optional(), }) - .optional(), + .nullish(), // API 可能返回 null 或 undefined error: z .object({ code: z.number(), message: z.string(), info: z.string().optional(), }) - .optional(), + .nullish(), }) export type AddressAssetsResponse = z.infer From 8f0cec5942fbdc36809cb4375987f8c26f1fb4ed Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 20:20:41 +0800 Subject: [PATCH 19/27] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20supportsTran?= =?UTF-8?q?sactionHistory=20=E5=88=B0=20ITransactionService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EVM/Tron: false (需要浏览器查询) - Bioforest/Bitcoin/Bip39: true - AddressTransactionsPage 通过 adapter 属性判断,不再硬编码类型 --- src/i18n/locales/en/common.json | 1 + src/i18n/locales/zh-CN/common.json | 1 + src/pages/address-transactions/index.tsx | 24 +++++++++++++++++-- .../bioforest/transaction-service.ts | 1 + .../bip39/transaction-service.ts | 1 + .../bitcoin/transaction-service.ts | 1 + .../chain-adapter/evm/transaction-service.ts | 1 + .../chain-adapter/tron/transaction-service.ts | 1 + src/services/chain-adapter/types.ts | 2 ++ 9 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d9cb3c6a..ce623d10 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -367,6 +367,7 @@ "noTransactions": "No transactions found", "onChain": "on {{chain}}", "explorerHint": "Transaction history requires block explorer", + "useExplorerHint": "This chain does not support direct history query, please use the explorer", "openExplorer": "Open {{name}} Explorer", "viewOnExplorer": "View on {{name}}" } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 662c1ba1..b5efc57b 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -345,6 +345,7 @@ "noTransactions": "暂无交易记录", "onChain": "在 {{chain}} 上", "explorerHint": "交易记录需要通过区块浏览器查询", + "useExplorerHint": "该链不支持直接查询交易历史,请使用浏览器查看", "openExplorer": "打开 {{name}} 浏览器", "viewOnExplorer": "在 {{name}} 浏览器中查看" } diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx index 9e02d58b..24ddd5ab 100644 --- a/src/pages/address-transactions/index.tsx +++ b/src/pages/address-transactions/index.tsx @@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Card, CardContent } from '@/components/ui/card' import { useEnabledChains } from '@/stores' import { useAddressTransactionsQuery } from '@/queries' +import { getAdapterRegistry, setupAdapters, registerChainConfigs } from '@/services/chain-adapter' import { IconSearch, IconExternalLink, IconArrowUpRight, IconArrowDownLeft, IconLoader2 } from '@tabler/icons-react' import type { Transaction } from '@/services/chain-adapter/types' @@ -58,6 +59,20 @@ export function AddressTransactionsPage() { [enabledChains, selectedChain] ) + // 检查当前链的 adapter 是否支持交易历史查询 + const supportsTransactionHistory = useMemo(() => { + if (!selectedChainConfig) return false + try { + setupAdapters() + registerChainConfigs([selectedChainConfig]) + const registry = getAdapterRegistry() + const adapter = registry.getAdapter(selectedChain) + return adapter?.transaction.supportsTransactionHistory ?? false + } catch { + return false + } + }, [selectedChain, selectedChainConfig]) + const { data: transactions, isLoading, isError, refetch } = useAddressTransactionsQuery({ chainId: selectedChain, address: searchAddress, @@ -177,7 +192,12 @@ export function AddressTransactionsPage() { ) : (
- {t('common:addressLookup.noTransactions')} + {/* 不支持直接查询历史的链,显示浏览器提示 */} + {!supportsTransactionHistory ? ( +

{t('common:addressLookup.useExplorerHint')}

+ ) : ( +

{t('common:addressLookup.noTransactions')}

+ )}
)} @@ -185,7 +205,7 @@ export function AddressTransactionsPage() { {explorerUrl && (