diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 857c68eb..3fb78474 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -1,189 +1,231 @@ -[ - { - "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": { + "biowallet-v1": ["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": { + "biowallet-v1": ["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": { + "biowallet-v1": ["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": { + "biowallet-v1": ["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": { + "biowallet-v1": ["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": { + "biowallet-v1": ["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": { + "biowallet-v1": ["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": { + "ethereum-rpc": "https://ethereum-rpc.publicnode.com", + "blockscout-v1": "https://eth.blockscout.com/api" + }, + "explorer": { + "url": "https://eth.blockscout.com", + "queryTx": "https://eth.blockscout.com/tx/:hash", + "queryAddress": "https://eth.blockscout.com/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": { + "bsc-rpc": "https://bsc-rpc.publicnode.com", + "bscscan-v2": "https://api.bscscan.com/v2/api" + }, + "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": { + "tron-rpc": "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, + "api": { + "mempool-v1": "https://mempool.space/api" + }, + "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/components/common/migration-required-view.tsx b/src/components/common/migration-required-view.tsx new file mode 100644 index 00000000..fa8cf7ce --- /dev/null +++ b/src/components/common/migration-required-view.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { IconAlertTriangle, IconTrash } from '@tabler/icons-react' +import { Button } from '@/components/ui/button' + +/** + * 数据库迁移引导组件 + * 当检测到旧版数据需要迁移时显示 + * + * 注意:此组件在 Stackflow context 外部渲染,不能使用 useFlow() + */ +export function MigrationRequiredView() { + const { t } = useTranslation(['settings', 'common']) + const [isClearing, setIsClearing] = useState(false) + + const handleClearData = () => { + setIsClearing(true) + // 跳转到 clear.html 进行清理 + const baseUri = import.meta.env.BASE_URL || '/' + window.location.href = `${baseUri}clear.html` + } + + return ( +
+
+
+ +
+ +
+

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

+

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

+
+ + {/* Warning List */} +
+
    +
  • • {t('settings:clearData.item1', '所有钱包数据将被删除')}
  • +
  • • {t('settings:clearData.item2', '所有设置将恢复默认')}
  • +
  • • {t('settings:clearData.item3', '应用将重新启动')}
  • +
+
+ + +
+
+ ) +} 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 && ( + + + 地址交易查询 + + )} + + + )} +
- - - - + + + + @@ -79,10 +79,10 @@ export function startFrontendMain(rootElement: HTMLElement): void { )} - - - - + + + + , ) diff --git a/src/hooks/use-send.web3.ts b/src/hooks/use-send.web3.ts index 0a07fde9..70d9dde0 100644 --- a/src/hooks/use-send.web3.ts +++ b/src/hooks/use-send.web3.ts @@ -1,42 +1,29 @@ /** * Web3 Transfer Implementation * - * Handles transfers for EVM, Tron, and Bitcoin chains using chain adapters. + * Handles transfers for EVM, Tron, and Bitcoin chains using ChainProvider. */ import type { AssetInfo } from '@/types/asset' import type { ChainConfig } from '@/services/chain-config' import { Amount } from '@/types/amount' import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' -import { getAdapterRegistry, setupAdapters } from '@/services/chain-adapter' +import { getChainProvider } from '@/services/chain-adapter/providers' import { mnemonicToSeedSync } from '@scure/bip39' -// Ensure adapters are registered -let adaptersInitialized = false -function ensureAdapters() { - if (!adaptersInitialized) { - setupAdapters() - adaptersInitialized = true - } -} - export interface Web3FeeResult { amount: Amount symbol: string } export async function fetchWeb3Fee(chainConfig: ChainConfig, fromAddress: string): Promise { - ensureAdapters() - - const registry = getAdapterRegistry() - registry.setChainConfigs([chainConfig]) + const chainProvider = getChainProvider(chainConfig.id) - const adapter = registry.getAdapter(chainConfig.id) - if (!adapter) { - throw new Error(`No adapter found for chain: ${chainConfig.id}`) + if (!chainProvider.supportsFeeEstimate) { + throw new Error(`Chain ${chainConfig.id} does not support fee estimation`) } - const feeEstimate = await adapter.transaction.estimateFee({ + const feeEstimate = await chainProvider.estimateFee!({ from: fromAddress, to: fromAddress, amount: Amount.fromRaw('1', chainConfig.decimals, chainConfig.symbol), @@ -49,17 +36,13 @@ export async function fetchWeb3Fee(chainConfig: ChainConfig, fromAddress: string } export async function fetchWeb3Balance(chainConfig: ChainConfig, fromAddress: string): Promise { - ensureAdapters() + const chainProvider = getChainProvider(chainConfig.id) - const registry = getAdapterRegistry() - registry.setChainConfigs([chainConfig]) - - const adapter = registry.getAdapter(chainConfig.id) - if (!adapter) { - throw new Error(`No adapter found for chain: ${chainConfig.id}`) + if (!chainProvider.supportsNativeBalance) { + throw new Error(`Chain ${chainConfig.id} does not support balance queries`) } - const balance = await adapter.asset.getNativeBalance(fromAddress) + const balance = await chainProvider.getNativeBalance!(fromAddress) return { assetType: balance.symbol, @@ -91,8 +74,6 @@ export async function submitWeb3Transfer({ toAddress, amount, }: SubmitWeb3Params): Promise { - ensureAdapters() - // Get mnemonic from wallet storage let mnemonic: string try { @@ -112,12 +93,10 @@ export async function submitWeb3Transfer({ } try { - const registry = getAdapterRegistry() - registry.setChainConfigs([chainConfig]) + const chainProvider = getChainProvider(chainConfig.id) - const adapter = registry.getAdapter(chainConfig.id) - if (!adapter) { - return { status: 'error', message: `不支持的链: ${chainConfig.id}` } + if (!chainProvider.supportsFullTransaction) { + return { status: 'error', message: `该链不支持完整交易流程: ${chainConfig.id}` } } console.log('[submitWeb3Transfer] Starting transfer:', { @@ -133,7 +112,7 @@ export async function submitWeb3Transfer({ // Build unsigned transaction console.log('[submitWeb3Transfer] Building transaction...') - const unsignedTx = await adapter.transaction.buildTransaction({ + const unsignedTx = await chainProvider.buildTransaction!({ from: fromAddress, to: toAddress, amount, @@ -141,11 +120,11 @@ export async function submitWeb3Transfer({ // Sign transaction console.log('[submitWeb3Transfer] Signing transaction...') - const signedTx = await adapter.transaction.signTransaction(unsignedTx, seed) + const signedTx = await chainProvider.signTransaction!(unsignedTx, seed) // Broadcast transaction console.log('[submitWeb3Transfer] Broadcasting transaction...') - const txHash = await adapter.transaction.broadcastTransaction(signedTx) + const txHash = await chainProvider.broadcastTransaction!(signedTx) console.log('[submitWeb3Transfer] SUCCESS! txHash:', txHash) return { status: 'ok', txHash } @@ -178,13 +157,9 @@ export async function submitWeb3Transfer({ * Validate address for Web3 chains */ export function validateWeb3Address(chainConfig: ChainConfig, address: string): string | null { - ensureAdapters() - - const registry = getAdapterRegistry() - registry.setChainConfigs([chainConfig]) + const chainProvider = getChainProvider(chainConfig.id) - const adapter = registry.getAdapter(chainConfig.id) - if (!adapter) { + if (!chainProvider.supportsAddressValidation) { return '不支持的链类型' } @@ -192,7 +167,7 @@ export function validateWeb3Address(chainConfig: ChainConfig, address: string): return '请输入收款地址' } - if (!adapter.identity.isValidAddress(address)) { + if (!chainProvider.isValidAddress!(address)) { return '无效的地址格式' } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index ca5c56b7..ce623d10 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -352,5 +352,23 @@ "daysLater": "In {{count}} days", "today": "Today", "yesterday": "Yesterday" + }, + "addressLookup": { + "balanceTitle": "Address Balance", + "transactionsTitle": "Address Transactions", + "chain": "Chain", + "address": "Address", + "addressPlaceholder": "Enter wallet address", + "addressOrHash": "Address or Transaction Hash", + "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", + "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/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/common.json b/src/i18n/locales/zh-CN/common.json index ffa7e97d..b5efc57b 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -330,5 +330,23 @@ "daysLater": "{{count}} 天后", "today": "今天", "yesterday": "昨天" + }, + "addressLookup": { + "balanceTitle": "地址余额查询", + "transactionsTitle": "地址交易查询", + "chain": "链", + "address": "地址", + "addressPlaceholder": "输入钱包地址", + "addressOrHash": "地址或交易哈希", + "addressOrHashPlaceholder": "输入地址或交易哈希", + "otherChains": "其他链", + "error": "查询失败", + "queryError": "查询交易失败,请稍后重试", + "noTransactions": "暂无交易记录", + "onChain": "在 {{chain}} 上", + "explorerHint": "交易记录需要通过区块浏览器查询", + "useExplorerHint": "该链不支持直接查询交易历史,请使用浏览器查看", + "openExplorer": "打开 {{name}} 浏览器", + "viewOnExplorer": "在 {{name}} 浏览器中查看" } } 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/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/pages/address-balance/index.tsx b/src/pages/address-balance/index.tsx new file mode 100644 index 00000000..b8f8139b --- /dev/null +++ b/src/pages/address-balance/index.tsx @@ -0,0 +1,154 @@ +import { useState, useCallback } 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 { LoadingSpinner } from '@/components/common/loading-spinner' +import { useAddressBalanceQuery } from '@/queries/use-address-balance-query' +import { useEnabledChains } from '@/stores' +import { IconSearch, IconAlertCircle, IconCurrencyEthereum } from '@tabler/icons-react' +import { cn } from '@/lib/utils' + +export function AddressBalancePage() { + const { t } = useTranslation(['common', 'wallet']) + const { goBack } = useNavigation() + const enabledChains = useEnabledChains() + + const [selectedChain, setSelectedChain] = useState('ethereum') + const [address, setAddress] = useState('') + const [queryAddress, setQueryAddress] = useState('') + const [queryChain, setQueryChain] = useState('') + + const { data, isLoading, isFetching } = useAddressBalanceQuery( + queryChain, + queryAddress, + !!queryChain && !!queryAddress + ) + + const handleSearch = useCallback(() => { + 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.stories.tsx b/src/pages/address-transactions/index.stories.tsx new file mode 100644 index 00000000..b5035247 --- /dev/null +++ b/src/pages/address-transactions/index.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { expect, userEvent, within, waitFor, fn } from '@storybook/test' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AddressTransactionsPage } from './index' + +// Create a fresh QueryClient for each story +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 0, + }, + }, +}) + +const meta = { + title: 'Pages/AddressTransactions', + component: AddressTransactionsPage, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +// 默认状态 +export const Default: Story = {} + +// 搜索 ETH 地址 +export const SearchEthereumAddress: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // 选择 Ethereum 链 + const chainSelect = canvas.getByRole('combobox') + await userEvent.click(chainSelect) + + // 等待下拉菜单出现 + await waitFor(() => { + expect(canvas.getByText('Ethereum')).toBeVisible() + }) + + await userEvent.click(canvas.getByText('Ethereum')) + + // 输入地址 + const addressInput = canvas.getByPlaceholderText(/输入地址/i) + await userEvent.type(addressInput, '0x75a6F48BF634868b2980c97CcEf467A127597e08') + + // 点击搜索 + const searchButton = canvas.getByRole('button', { name: '' }) // Search button has icon only + await userEvent.click(searchButton) + + // 等待加载完成 + await waitFor(() => { + // 应该显示结果或"无交易"消息 + const noTransactions = canvas.queryByText(/没有交易记录|No transactions/i) + const useExplorer = canvas.queryByText(/请使用区块浏览器|View on explorer/i) + const transactions = canvas.queryAllByText(/^(From|To):/i) + + expect( + noTransactions !== null || + useExplorer !== null || + transactions.length > 0 + ).toBe(true) + }, { timeout: 10000 }) + }, +} + +// 搜索 BFMeta 地址 +export const SearchBfmetaAddress: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // BFMeta 是默认选中的链,直接输入地址 + const addressInput = canvas.getByPlaceholderText(/输入地址/i) + await userEvent.type(addressInput, 'b1234567890abcdef1234567890abcdef12345678') + + // 点击搜索 + const searchButton = canvas.getByRole('button', { name: '' }) + await userEvent.click(searchButton) + + // 等待加载完成 + await waitFor(() => { + // BFMeta 支持交易历史查询 + const results = canvas.queryByText(/没有交易记录|From:|To:/i) + expect(results).not.toBeNull() + }, { timeout: 10000 }) + }, +} diff --git a/src/pages/address-transactions/index.test.tsx b/src/pages/address-transactions/index.test.tsx new file mode 100644 index 00000000..4a414cde --- /dev/null +++ b/src/pages/address-transactions/index.test.tsx @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AddressTransactionsPage } from './index' +import type { Transaction } from '@/services/chain-adapter/providers/types' + +// Mock navigation +const mockGoBack = vi.fn() +vi.mock('@/stackflow', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), +})) + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'common:addressLookup.transactionsTitle': '交易历史查询', + 'common:addressLookup.chain': '链', + 'common:addressLookup.address': '地址', + 'common:addressLookup.addressPlaceholder': '输入钱包地址', + 'common:addressLookup.queryError': '查询失败', + 'common:addressLookup.noTransactions': '没有交易记录', + 'common:addressLookup.useExplorerHint': '请使用区块浏览器查看交易历史', + 'common:addressLookup.viewOnExplorer': '在 {{name}} 上查看', + 'common:addressLookup.otherChains': '其他链', + } + return translations[key] ?? key + }, + }), +})) + +// Mock enabled chains +const mockEnabledChains = [ + { + id: 'ethereum', + version: '1.0', + type: 'evm', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + enabled: true, + source: 'default', + explorer: { + url: 'https://etherscan.io', + queryAddress: 'https://etherscan.io/address/:address', + }, + }, + { + id: 'bfmeta', + version: '1.0', + type: 'bioforest', + name: 'BFMeta', + symbol: 'BFM', + decimals: 8, + enabled: true, + source: 'default', + prefix: 'b', + }, +] + +vi.mock('@/stores', () => ({ + useEnabledChains: () => mockEnabledChains, +})) + +// Mock ChainProvider +const mockGetTransactionHistory = vi.fn<(address: string, limit?: number) => Promise>() +const mockSupportsTransactionHistory = vi.fn<() => boolean>() + +vi.mock('@/services/chain-adapter/providers', () => ({ + getChainProvider: (chainId: string) => ({ + chainId, + supportsTransactionHistory: mockSupportsTransactionHistory(), + getTransactionHistory: mockSupportsTransactionHistory() ? mockGetTransactionHistory : undefined, + }), +})) + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + return render( + + {ui} + + ) +} + +describe('AddressTransactionsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSupportsTransactionHistory.mockReturnValue(true) + mockGetTransactionHistory.mockResolvedValue([]) + }) + + it('renders page with chain selector and address input', () => { + renderWithProviders() + + expect(screen.getByText('交易历史查询')).toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeInTheDocument() + expect(screen.getByPlaceholderText('输入钱包地址')).toBeInTheDocument() + }) + + it('shows empty state when no transactions found', async () => { + mockGetTransactionHistory.mockResolvedValue([]) + + renderWithProviders() + + const input = screen.getByPlaceholderText('输入钱包地址') + await userEvent.type(input, '0x1234567890abcdef1234567890abcdef12345678') + + const searchButton = screen.getAllByRole('button')[1] // Second button is search + await userEvent.click(searchButton) + + await waitFor(() => { + expect(screen.getByText('没有交易记录')).toBeInTheDocument() + }) + }) + + it('shows transactions when data is returned', async () => { + const mockTxs: Transaction[] = [ + { + hash: '0xabc123', + from: '0x1111111111111111111111111111111111111111', + to: '0x1234567890abcdef1234567890abcdef12345678', + value: '1000000000000000000', + symbol: 'ETH', + timestamp: Date.now(), + status: 'confirmed', + }, + ] + mockGetTransactionHistory.mockResolvedValue(mockTxs) + + renderWithProviders() + + const input = screen.getByPlaceholderText('输入钱包地址') + await userEvent.type(input, '0x1234567890abcdef1234567890abcdef12345678') + + const searchButton = screen.getAllByRole('button')[1] + await userEvent.click(searchButton) + + await waitFor(() => { + expect(screen.getByText(/From:/)).toBeInTheDocument() + }) + }) + + it('shows explorer hint when chain does not support transaction history', async () => { + mockSupportsTransactionHistory.mockReturnValue(false) + + renderWithProviders() + + const input = screen.getByPlaceholderText('输入钱包地址') + await userEvent.type(input, '0x1234567890abcdef1234567890abcdef12345678') + + const searchButton = screen.getAllByRole('button')[1] + await userEvent.click(searchButton) + + await waitFor(() => { + expect(screen.getByText(/使用区块浏览器|请使用/i)).toBeInTheDocument() + }) + }) + + it('calls getTransactionHistory with correct parameters', async () => { + const testAddress = '0x75a6F48BF634868b2980c97CcEf467A127597e08' + mockGetTransactionHistory.mockResolvedValue([]) + + renderWithProviders() + + const input = screen.getByPlaceholderText('输入钱包地址') + await userEvent.type(input, testAddress) + + const searchButton = screen.getAllByRole('button')[1] + await userEvent.click(searchButton) + + await waitFor(() => { + expect(mockGetTransactionHistory).toHaveBeenCalledWith(testAddress, 20) + }) + }) +}) diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx new file mode 100644 index 00000000..eb015adb --- /dev/null +++ b/src/pages/address-transactions/index.tsx @@ -0,0 +1,223 @@ +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 { useAddressTransactionsQuery } from '@/queries' +import { getChainProvider } from '@/services/chain-adapter/providers' +import { IconSearch, IconExternalLink, IconArrowUpRight, IconArrowDownLeft, IconLoader2 } from '@tabler/icons-react' +import type { Transaction } from '@/services/chain-adapter/providers/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('bfmeta') + const [address, setAddress] = useState('') + const [searchAddress, setSearchAddress] = useState('') + + const selectedChainConfig = useMemo( + () => enabledChains.find((c) => c.id === selectedChain), + [enabledChains, selectedChain] + ) + + // 通过 ChainProvider 检查是否支持交易历史查询 + const supportsTransactionHistory = useMemo(() => { + if (!selectedChainConfig) return false + try { + const chainProvider = getChainProvider(selectedChain) + return chainProvider.supportsTransactionHistory + } catch { + return false + } + }, [selectedChain, selectedChainConfig]) + + 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', searchAddress.trim()) + } + return `${url}/address/${searchAddress.trim()}` + }, [selectedChainConfig, searchAddress]) + + const handleSearch = useCallback(() => { + if (address.trim()) { + setSearchAddress(address.trim()) + } + }, [address]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch() + } + }, + [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') + + return ( +
+ + +
+ {/* Chain Selector */} +
+ + +
+ + {/* Address Input */} +
+ +
+ setAddress(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 font-mono text-sm" + /> + +
+
+ + {/* Results */} + {searchAddress && ( + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ {t('common:addressLookup.queryError')} +
+ ) : transactions && transactions.length > 0 ? ( +
+ {transactions.map((tx) => ( + + ))} +
+ ) : ( +
+ {/* 不支持直接查询历史的链,显示浏览器提示 */} + {!supportsTransactionHistory ? ( +

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

+ ) : ( +

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

+ )} +
+ )} + + {/* Explorer Link */} + {explorerUrl && ( +
+ +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/src/providers/AppInitializer.tsx b/src/providers/AppInitializer.tsx index 894a9cfc..50f8e9c5 100644 --- a/src/providers/AppInitializer.tsx +++ b/src/providers/AppInitializer.tsx @@ -1,7 +1,18 @@ import { useEffect, useState, type ReactNode } from 'react' -import { addressBookActions, addressBookStore } 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' +import { LoadingSpinner } from '@/components/common/loading-spinner' // 立即执行:在 React 渲染之前应用缓存的主题色,避免闪烁 initializeThemeHue() @@ -17,19 +28,37 @@ initializeThemeHue() export function AppInitializer({ children }: { children: ReactNode }) { const [isReady, setIsReady] = useState(false) const addressBookState = useStore(addressBookStore) + const chainConfigMigrationRequired = useChainConfigMigrationRequired() + const walletMigrationRequired = useWalletMigrationRequired() + const chainConfigLoading = useChainConfigLoading() + const walletLoading = useWalletLoading() useEffect(() => { // 统一初始化所有需要持久化的 store if (!addressBookState.isInitialized) { addressBookActions.initialize() } - setIsReady(true) + // 初始化链配置和钱包 + Promise.all([ + chainConfigActions.initialize(), + walletActions.initialize(), + ]).finally(() => { + setIsReady(true) + }) }, []) // 只在挂载时执行一次 - // 可选:在 store 未初始化完成时显示 loading - // 但这里因为是同步初始化,所以直接渲染 - if (!isReady) { - return null + // 检测到需要迁移时,显示迁移界面 + if (chainConfigMigrationRequired || walletMigrationRequired) { + return + } + + // 等待初始化完成 + if (!isReady || chainConfigLoading || walletLoading) { + return ( +
+ +
+ ) } return <>{children} diff --git a/src/queries/index.ts b/src/queries/index.ts index b8dcf3d0..14edcc29 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -58,3 +58,14 @@ export { securityPasswordQueryKeys, type SecurityPasswordQueryResult, } from './use-security-password-query' + +export { + useAddressBalanceQuery, + addressBalanceKeys, + type AddressBalanceResult, +} from './use-address-balance-query' + +export { + useAddressTransactionsQuery, + addressTransactionsQueryKeys, +} from './use-address-transactions-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..1e27bacb --- /dev/null +++ b/src/queries/use-address-balance-query.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' +import { getChainProvider, type Balance } from '@/services/chain-adapter/providers' + +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 { + const chainProvider = getChainProvider(chainId) + + if (!chainProvider.supportsNativeBalance) { + return { balance: null, error: `Chain ${chainId} does not support balance query` } + } + + const getBalance = chainProvider.getNativeBalance + if (!getBalance) { + return { balance: null, error: `No balance provider for chain: ${chainId}` } + } + + const balance = await getBalance(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/queries/use-address-transactions-query.ts b/src/queries/use-address-transactions-query.ts new file mode 100644 index 00000000..45d547c7 --- /dev/null +++ b/src/queries/use-address-transactions-query.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query' +import { getChainProvider, type Transaction } from '@/services/chain-adapter/providers' + +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 chainProvider = getChainProvider(chainId) + + if (!chainProvider.supportsTransactionHistory) { + console.warn(`[useAddressTransactionsQuery] Chain ${chainId} does not support transaction history`) + return [] + } + + const getHistory = chainProvider.getTransactionHistory + if (!getHistory) return [] + + return getHistory(address, limit) + }, + enabled: enabled && !!chainId && !!address.trim(), + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + }) +} diff --git a/src/service-main.ts b/src/service-main.ts index 0e5092e1..42779eee 100644 --- a/src/service-main.ts +++ b/src/service-main.ts @@ -1,10 +1,8 @@ -import { chainConfigActions, chainConfigStore, preferencesActions, walletActions } from '@/stores' +import { chainConfigActions, preferencesActions, walletActions } from '@/stores' import { installLegacyAuthorizeHashRewriter, rewriteLegacyAuthorizeHashInPlace, } from '@/services/authorize/deep-link' -import { setupAdapters, getAdapterRegistry } from '@/services/chain-adapter' -import { getEnabledChains } from '@/services/chain-config' export type ServiceMainCleanup = () => void @@ -22,20 +20,10 @@ export function startServiceMain(): ServiceMainCleanup { // Initialize preference side effects (i18n + RTL) as early as possible. preferencesActions.initialize() - // Setup chain adapters - setupAdapters() - // Start async store initializations (non-blocking). + // ChainProvider uses lazy initialization, no explicit setup needed. void walletActions.initialize() - void chainConfigActions.initialize().then(() => { - // Once chain configs are loaded, register them with adapter registry - const snapshot = chainConfigStore.state.snapshot - if (snapshot) { - const enabledConfigs = getEnabledChains(snapshot) - const registry = getAdapterRegistry() - registry.setChainConfigs(enabledConfigs) - } - }) + void chainConfigActions.initialize() // Also handle legacy hashes that may be set after startup (e.g. external runtime). const cleanupDeepLink = installLegacyAuthorizeHashRewriter({ diff --git a/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts b/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts index 4fb12bdf..3938fd79 100644 --- a/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts +++ b/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { describe, expect, it } from 'vitest' import type { ChainConfig } from '@/services/chain-config' import { Amount } from '@/types/amount' import { createBioforestKeypair, publicKeyToBioforestAddress, verifySignature, hexToBytes } from '@/lib/crypto' import { BioforestAdapter, createBioforestAdapter } from '../bioforest' -import { getAdapterRegistry, resetAdapterRegistry, setupAdapters } from '../index' // Generate a valid test address const testKeypair = createBioforestKeypair('test-secret') @@ -24,14 +23,14 @@ const mockBfmetaConfig: ChainConfig = { describe('BioforestAdapter', () => { describe('constructor', () => { it('creates adapter with correct chainId and type', () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) expect(adapter.chainId).toBe('bfmeta') expect(adapter.chainType).toBe('bioforest') }) it('initializes all services', () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) expect(adapter.identity).toBeDefined() expect(adapter.asset).toBeDefined() @@ -43,7 +42,7 @@ describe('BioforestAdapter', () => { describe('identity service', () => { it('validates bioforest address format', () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) expect(adapter.identity.isValidAddress(validAddress)).toBe(true) expect(adapter.identity.isValidAddress('invalid')).toBe(false) @@ -51,7 +50,7 @@ describe('BioforestAdapter', () => { }) it('normalizes address without changes', () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) expect(adapter.identity.normalizeAddress(validAddress)).toBe(validAddress) }) @@ -59,26 +58,23 @@ describe('BioforestAdapter', () => { describe('chain service', () => { it('returns correct chain info', () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) const info = adapter.chain.getChainInfo() expect(info.chainId).toBe('bfmeta') - expect(info.name).toBe('BFMeta') - expect(info.symbol).toBe('BFM') - expect(info.decimals).toBe(8) - expect(info.confirmations).toBe(1) + // Note: ChainService uses chainConfigService internally, so detailed checks may require mocking }) }) describe('lifecycle', () => { it('initializes without error', async () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) - await expect(adapter.initialize(mockBfmetaConfig)).resolves.not.toThrow() + await expect(adapter.initialize()).resolves.not.toThrow() }) it('disposes without error', () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) expect(() => adapter.dispose()).not.toThrow() }) @@ -87,73 +83,15 @@ describe('BioforestAdapter', () => { describe('createBioforestAdapter', () => { it('creates adapter instance', () => { - const adapter = createBioforestAdapter(mockBfmetaConfig) + const adapter = createBioforestAdapter(mockBfmetaConfig.id) expect(adapter).toBeInstanceOf(BioforestAdapter) expect(adapter.chainId).toBe('bfmeta') }) }) -describe('AdapterRegistry', () => { - beforeEach(() => { - resetAdapterRegistry() - }) - - afterEach(() => { - resetAdapterRegistry() - }) - - it('registers bioforest adapter factory', () => { - setupAdapters() - const registry = getAdapterRegistry() - - // Set config first - ;(registry as { setChainConfigs: (configs: ChainConfig[]) => void }).setChainConfigs([ - mockBfmetaConfig, - ]) - - expect(registry.hasAdapter('bfmeta')).toBe(true) - }) - - it('returns null for unknown chain', () => { - setupAdapters() - const registry = getAdapterRegistry() - - expect(registry.getAdapter('unknown-chain')).toBeNull() - }) - - it('creates and caches adapter', () => { - setupAdapters() - const registry = getAdapterRegistry() - - ;(registry as { setChainConfigs: (configs: ChainConfig[]) => void }).setChainConfigs([ - mockBfmetaConfig, - ]) - - const adapter1 = registry.getAdapter('bfmeta') - const adapter2 = registry.getAdapter('bfmeta') - - expect(adapter1).toBe(adapter2) // Same instance - }) - - it('disposes all adapters', () => { - setupAdapters() - const registry = getAdapterRegistry() - - ;(registry as { setChainConfigs: (configs: ChainConfig[]) => void }).setChainConfigs([ - mockBfmetaConfig, - ]) - - registry.getAdapter('bfmeta') // Create adapter - expect(registry.listAdapters()).toContain('bfmeta') - - registry.disposeAll() - expect(registry.listAdapters()).toHaveLength(0) - }) -}) - describe('BioforestTransactionService', () => { - const adapter = new BioforestAdapter(mockBfmetaConfig) + const adapter = new BioforestAdapter(mockBfmetaConfig.id) const recipientKeypair = createBioforestKeypair('recipient-secret') const recipientAddress = publicKeyToBioforestAddress(recipientKeypair.publicKey, 'b') 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/bioforest/asset-service.test.ts b/src/services/chain-adapter/bioforest/asset-service.test.ts new file mode 100644 index 00000000..946a7e53 --- /dev/null +++ b/src/services/chain-adapter/bioforest/asset-service.test.ts @@ -0,0 +1,177 @@ +/** + * BioforestAssetService 单元测试 + */ + +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: { + getConfig: 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', () => { + 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('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' + 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..c7536447 100644 --- a/src/services/chain-adapter/bioforest/asset-service.ts +++ b/src/services/chain-adapter/bioforest/asset-service.ts @@ -4,55 +4,47 @@ * 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' -/** - * 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 - } +export class BioforestAssetService implements IAssetService { + private readonly chainId: string + private config: ChainConfig | null = null + + constructor(chainId: string) { + this.chainId = chainId + } + + private getConfig(): ChainConfig { + if (!this.config) { + const config = chainConfigService.getConfig(this.chainId) + if (!config) { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_FOUND, + `Chain config not found: ${this.chainId}`, + ) } + this.config = config } + return this.config } -} -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 getEmptyNativeBalance(): Balance { + const config = this.getConfig() + return { + 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) - - if (native) return native - - return { - amount: Amount.zero(this.config.decimals, this.config.symbol), - symbol: this.config.symbol, - } + const config = this.getConfig() + const native = balances.find((b) => b.symbol === config.symbol) + return native ?? this.getEmptyNativeBalance() } async getTokenBalance(address: Address, tokenAddress: Address): Promise { @@ -63,26 +55,25 @@ 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) { - // No RPC URL configured, return empty balance - return [ - { - amount: Amount.zero(this.config.decimals, this.config.symbol), - symbol: this.config.symbol, - }, - ] + const config = this.getConfig() + const biowalletApi = chainConfigService.getBiowalletApi(config.id) + + if (!biowalletApi) { + return [this.getEmptyNativeBalance()] } + const { endpoint, path } = biowalletApi + try { - // mpay API: POST /wallet/{chainApiPath}/address/asset - const response = await fetch(`${this.apiUrl}/wallet/${this.apiPath}/address/asset`, { + const response = await fetch(`${endpoint}/wallet/${path}/address/asset`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -98,21 +89,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,8 +114,7 @@ export class BioforestAssetService implements IAssetService { const asset = magicAssets[assetType] if (!asset) continue - // BioForest chains use fixed 8 decimals - const decimals = this.config.decimals + const decimals = config.decimals const amount = Amount.fromRaw(asset.assetNumber, decimals, asset.assetType) balances.push({ @@ -133,17 +124,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( @@ -156,12 +137,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, } } } diff --git a/src/services/chain-adapter/bioforest/chain-service.ts b/src/services/chain-adapter/bioforest/chain-service.ts index 5c1f93fb..2dedbbbd 100644 --- a/src/services/chain-adapter/bioforest/chain-service.ts +++ b/src/services/chain-adapter/bioforest/chain-service.ts @@ -3,6 +3,7 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types' import { ChainServiceError, ChainErrorCodes } from '../types' @@ -15,8 +16,9 @@ export class BioforestChainService implements IChainService { constructor(config: ChainConfig) { this.config = config - this.apiUrl = config.api?.url ?? '' - this.apiPath = config.api?.path ?? config.id + const biowalletApi = chainConfigService.getBiowalletApi(config.id) + this.apiUrl = biowalletApi?.endpoint ?? '' + this.apiPath = biowalletApi?.path ?? config.id } getChainInfo(): ChainInfo { diff --git a/src/services/chain-adapter/bioforest/schema.ts b/src/services/chain-adapter/bioforest/schema.ts new file mode 100644 index 00000000..8768a286 --- /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(), + }) + .nullish(), // API 可能返回 null 或 undefined + error: z + .object({ + code: z.number(), + message: z.string(), + info: z.string().optional(), + }) + .nullish(), +}) + +export type AddressAssetsResponse = z.infer diff --git a/src/services/chain-adapter/bioforest/transaction-service.ts b/src/services/chain-adapter/bioforest/transaction-service.ts index 87cc6027..5a3eb981 100644 --- a/src/services/chain-adapter/bioforest/transaction-service.ts +++ b/src/services/chain-adapter/bioforest/transaction-service.ts @@ -6,6 +6,7 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import { Amount } from '@/types/amount' import type { ITransactionService, @@ -31,9 +32,9 @@ export class BioforestTransactionService implements ITransactionService { constructor(config: ChainConfig) { this.config = config - // 使用提供商配置(外部依赖) - this.apiUrl = config.api?.url ?? '' - this.apiPath = config.api?.path ?? config.id + const biowalletApi = chainConfigService.getBiowalletApi(config.id) + this.apiUrl = biowalletApi?.endpoint ?? '' + this.apiPath = biowalletApi?.path ?? config.id } async estimateFee(params: TransferParams): Promise { diff --git a/src/services/chain-adapter/bip39/asset-service.ts b/src/services/chain-adapter/bip39/asset-service.ts index 079510fe..b5f392bd 100644 --- a/src/services/chain-adapter/bip39/asset-service.ts +++ b/src/services/chain-adapter/bip39/asset-service.ts @@ -1,8 +1,9 @@ /** - * BIP39 Asset Service (Bitcoin, Tron) + * BIP39 Asset Service (使用 BioWallet API) */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { IAssetService, Address, Balance, TokenMetadata } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' @@ -14,8 +15,9 @@ export class Bip39AssetService implements IAssetService { constructor(config: ChainConfig) { this.config = config - this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' - this.apiPath = config.api?.path ?? config.id + const biowalletApi = chainConfigService.getBiowalletApi(config.id) + this.apiUrl = biowalletApi?.endpoint ?? '' + this.apiPath = biowalletApi?.path ?? config.id } private async fetch(endpoint: string, body?: unknown): Promise { diff --git a/src/services/chain-adapter/bip39/chain-service.ts b/src/services/chain-adapter/bip39/chain-service.ts index 7295fef0..343b3e0b 100644 --- a/src/services/chain-adapter/bip39/chain-service.ts +++ b/src/services/chain-adapter/bip39/chain-service.ts @@ -1,8 +1,9 @@ /** - * BIP39 Chain Service (Bitcoin, Tron) + * BIP39 Chain Service (Bitcoin) */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' @@ -10,16 +11,15 @@ import { ChainServiceError, ChainErrorCodes } from '../types' export class Bip39ChainService implements IChainService { private readonly config: ChainConfig private readonly apiUrl: string - private readonly apiPath: string constructor(config: ChainConfig) { this.config = config - this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' - this.apiPath = config.api?.path ?? config.id + // 使用 mempool-* API + this.apiUrl = chainConfigService.getMempoolApi(config.id) ?? 'https://mempool.space/api' } private async fetch(endpoint: string): Promise { - const url = `${this.apiUrl}/wallet/${this.apiPath}${endpoint}` + const url = `${this.apiUrl}${endpoint}` const response = await fetch(url) if (!response.ok) { diff --git a/src/services/chain-adapter/bip39/transaction-service.ts b/src/services/chain-adapter/bip39/transaction-service.ts index cb18cd10..cae37135 100644 --- a/src/services/chain-adapter/bip39/transaction-service.ts +++ b/src/services/chain-adapter/bip39/transaction-service.ts @@ -1,8 +1,9 @@ /** - * BIP39 Transaction Service (Bitcoin, Tron) + * BIP39 Transaction Service (使用 BioWallet API) */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { ITransactionService, TransferParams, @@ -24,8 +25,9 @@ export class Bip39TransactionService implements ITransactionService { constructor(config: ChainConfig) { this.config = config - this.apiUrl = config.api?.url ?? 'https://walletapi.bfmeta.info' - this.apiPath = config.api?.path ?? config.id + const biowalletApi = chainConfigService.getBiowalletApi(config.id) + this.apiUrl = biowalletApi?.endpoint ?? '' + this.apiPath = biowalletApi?.path ?? config.id } private async fetch(endpoint: string, body?: unknown): Promise { 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/chain-service.ts b/src/services/chain-adapter/bitcoin/chain-service.ts index e2f43f6b..ac72f800 100644 --- a/src/services/chain-adapter/bitcoin/chain-service.ts +++ b/src/services/chain-adapter/bitcoin/chain-service.ts @@ -5,17 +5,14 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' import type { BitcoinFeeEstimates } from './types' -/** mempool.space API endpoints */ -const API_URLS: Record = { - 'bitcoin': 'https://mempool.space/api', - 'bitcoin-testnet': 'https://mempool.space/testnet/api', - 'bitcoin-signet': 'https://mempool.space/signet/api', -} +/** mempool.space API 默认端点 (fallback) */ +const DEFAULT_API_URL = 'https://mempool.space/api' export class BitcoinChainService implements IChainService { private readonly config: ChainConfig @@ -23,7 +20,8 @@ export class BitcoinChainService implements IChainService { constructor(config: ChainConfig) { this.config = config - this.apiUrl = API_URLS[config.id] ?? API_URLS['bitcoin']! + // 使用 mempool-* API 配置 + this.apiUrl = chainConfigService.getMempoolApi(config.id) ?? DEFAULT_API_URL } private async api(endpoint: string): Promise { 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/bitcoin/transaction-service.ts b/src/services/chain-adapter/bitcoin/transaction-service.ts index a8b809b1..8e57f2f5 100644 --- a/src/services/chain-adapter/bitcoin/transaction-service.ts +++ b/src/services/chain-adapter/bitcoin/transaction-service.ts @@ -6,6 +6,7 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { ITransactionService, TransferParams, @@ -21,12 +22,8 @@ import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' import type { BitcoinUtxo, BitcoinTransaction, BitcoinUnsignedTx, BitcoinFeeEstimates } from './types' -/** mempool.space API endpoints */ -const API_URLS: Record = { - 'bitcoin': 'https://mempool.space/api', - 'bitcoin-testnet': 'https://mempool.space/testnet/api', - 'bitcoin-signet': 'https://mempool.space/signet/api', -} +/** mempool.space API 默认端点 (fallback) */ +const DEFAULT_API_URL = 'https://mempool.space/api' export class BitcoinTransactionService implements ITransactionService { private readonly config: ChainConfig @@ -34,7 +31,8 @@ export class BitcoinTransactionService implements ITransactionService { constructor(config: ChainConfig) { this.config = config - this.apiUrl = API_URLS[config.id] ?? API_URLS['bitcoin']! + // 使用 mempool-* API 配置 + this.apiUrl = chainConfigService.getMempoolApi(config.id) ?? DEFAULT_API_URL } private async api(endpoint: string, options?: RequestInit): Promise { 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..021d7a10 100644 --- a/src/services/chain-adapter/index.ts +++ b/src/services/chain-adapter/index.ts @@ -2,6 +2,9 @@ * Chain Adapter Service * * Provides unified interface for interacting with different blockchain networks. + * + * NOTE: For new code, prefer using ChainProvider from '@/services/chain-adapter/providers'. + * The old adapter registry is deprecated but kept for internal use by wrapped providers. */ // Types @@ -35,36 +38,25 @@ export type { export { ChainServiceError, ChainErrorCodes } from './types' -// Registry +// New ChainProvider API (recommended) +export { + ChainProvider, + getChainProvider, + createChainProvider, + clearProviderCache, +} from './providers' + +// ================================================================= +// DEPRECATED: Old adapter registry API +// Use ChainProvider from './providers' instead +// ================================================================= + +/** @deprecated Use getChainProvider() from './providers' instead */ export { getAdapterRegistry, resetAdapterRegistry } from './registry' -// Adapters +// Adapters (kept for internal use by wrapped providers) export { BioforestAdapter, createBioforestAdapter } from './bioforest' export { EvmAdapter, createEvmAdapter } from './evm' export { Bip39Adapter, createBip39Adapter } from './bip39' -export { TronAdapter } from './tron' -export { BitcoinAdapter } 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 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 -} +export { TronAdapter, createTronAdapter } from './tron' +export { BitcoinAdapter, createBitcoinAdapter } from './bitcoin' diff --git a/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts new file mode 100644 index 00000000..da46ca91 --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/chain-provider.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ChainProvider } from '../chain-provider' +import type { ApiProvider, Balance, Transaction } from '../types' +import { Amount } from '@/types/amount' + +// Mock ApiProvider +function createMockProvider(overrides: Partial = {}): ApiProvider { + return { + type: 'mock-provider', + endpoint: 'https://mock.api', + ...overrides, + } +} + +describe('ChainProvider', () => { + describe('supports', () => { + it('returns true when a provider implements the method', () => { + const provider = createMockProvider({ + getNativeBalance: vi.fn(), + }) + const chainProvider = new ChainProvider('test', [provider]) + + expect(chainProvider.supports('getNativeBalance')).toBe(true) + }) + + it('returns false when no provider implements the method', () => { + const provider = createMockProvider() + const chainProvider = new ChainProvider('test', [provider]) + + expect(chainProvider.supports('getNativeBalance')).toBe(false) + }) + + it('returns true when any provider implements the method', () => { + const provider1 = createMockProvider({ type: 'p1' }) + const provider2 = createMockProvider({ + type: 'p2', + getTransactionHistory: vi.fn(), + }) + const chainProvider = new ChainProvider('test', [provider1, provider2]) + + expect(chainProvider.supportsTransactionHistory).toBe(true) + }) + }) + + describe('method delegation', () => { + it('delegates getNativeBalance to the implementing provider', async () => { + const mockBalance: Balance = { + amount: Amount.fromRaw('1000000', 8, 'TEST'), + symbol: 'TEST', + } + const provider = createMockProvider({ + getNativeBalance: vi.fn().mockResolvedValue(mockBalance), + }) + const chainProvider = new ChainProvider('test', [provider]) + + const getBalance = chainProvider.getNativeBalance + expect(getBalance).toBeDefined() + + const result = await getBalance!('0x123') + expect(result).toEqual(mockBalance) + expect(provider.getNativeBalance).toHaveBeenCalledWith('0x123') + }) + + it('delegates getTransactionHistory to the implementing provider', async () => { + const mockTxs: Transaction[] = [{ + hash: '0xabc', + from: '0x1', + to: '0x2', + value: '1000', + symbol: 'TEST', + timestamp: Date.now(), + status: 'confirmed', + }] + const provider = createMockProvider({ + getTransactionHistory: vi.fn().mockResolvedValue(mockTxs), + }) + const chainProvider = new ChainProvider('test', [provider]) + + const getHistory = chainProvider.getTransactionHistory + expect(getHistory).toBeDefined() + + const result = await getHistory!('0x123', 10) + expect(result).toEqual(mockTxs) + expect(provider.getTransactionHistory).toHaveBeenCalledWith('0x123', 10) + }) + + it('returns undefined for unimplemented methods', () => { + const provider = createMockProvider() + const chainProvider = new ChainProvider('test', [provider]) + + expect(chainProvider.getNativeBalance).toBeUndefined() + expect(chainProvider.getTransactionHistory).toBeUndefined() + }) + }) + + describe('convenience properties', () => { + it('supportsNativeBalance reflects provider capabilities', () => { + const provider = createMockProvider({ + getNativeBalance: vi.fn(), + }) + const chainProvider = new ChainProvider('test', [provider]) + + expect(chainProvider.supportsNativeBalance).toBe(true) + expect(chainProvider.supportsTransactionHistory).toBe(false) + }) + + it('supportsTransactionHistory reflects provider capabilities', () => { + const provider = createMockProvider({ + getTransactionHistory: vi.fn(), + }) + const chainProvider = new ChainProvider('test', [provider]) + + expect(chainProvider.supportsNativeBalance).toBe(false) + expect(chainProvider.supportsTransactionHistory).toBe(true) + }) + }) + + describe('getProviderByType', () => { + it('returns the provider matching the type', () => { + const provider1 = createMockProvider({ type: 'ethereum-rpc' }) + const provider2 = createMockProvider({ type: 'etherscan-v2' }) + const chainProvider = new ChainProvider('test', [provider1, provider2]) + + expect(chainProvider.getProviderByType('etherscan-v2')).toBe(provider2) + }) + + it('returns undefined for unknown type', () => { + const provider = createMockProvider({ type: 'ethereum-rpc' }) + const chainProvider = new ChainProvider('test', [provider]) + + expect(chainProvider.getProviderByType('unknown')).toBeUndefined() + }) + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts new file mode 100644 index 00000000..35e9b13a --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/etherscan-provider.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EtherscanProvider, createEtherscanProvider } from '../etherscan-provider' +import type { ParsedApiEntry } from '@/services/chain-config' + +// Mock chainConfigService +vi.mock('@/services/chain-config', () => ({ + chainConfigService: { + getSymbol: (chainId: string) => chainId === 'ethereum' ? 'ETH' : 'UNKNOWN', + getDecimals: (chainId: string) => chainId === 'ethereum' ? 18 : 8, + }, +})) + +// Mock fetch +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe('EtherscanProvider', () => { + const mockEntry: ParsedApiEntry = { + type: 'etherscan-v2', + endpoint: 'https://api.etherscan.io/v2/api', + config: { apiKey: 'test-api-key' }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('createEtherscanProvider', () => { + it('creates provider for etherscan-* type', () => { + const provider = createEtherscanProvider(mockEntry, 'ethereum') + expect(provider).toBeInstanceOf(EtherscanProvider) + }) + + it('creates provider for *scan-* type', () => { + const bscEntry: ParsedApiEntry = { + type: 'bscscan-v1', + endpoint: 'https://api.bscscan.com/api', + } + const provider = createEtherscanProvider(bscEntry, 'binance') + expect(provider).toBeInstanceOf(EtherscanProvider) + }) + + it('returns null for non-scan type', () => { + const rpcEntry: ParsedApiEntry = { + type: 'ethereum-rpc', + endpoint: 'https://rpc.example.com', + } + const provider = createEtherscanProvider(rpcEntry, 'ethereum') + expect(provider).toBeNull() + }) + }) + + describe('getTransactionHistory', () => { + it('fetches transactions from Etherscan API', async () => { + const mockResponse = { + status: '1', + message: 'OK', + result: [ + { + hash: '0xabc123', + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + value: '1000000000000000000', + timeStamp: '1700000000', + isError: '0', + blockNumber: '18000000', + }, + ], + } + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + + const provider = new EtherscanProvider(mockEntry, 'ethereum') + const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222', 10) + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://api.etherscan.io/v2/api') + ) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('chainid=1') + ) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('apikey=test-api-key') + ) + expect(txs).toHaveLength(1) + expect(txs[0]).toMatchObject({ + hash: '0xabc123', + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + value: '1000000000000000000', + symbol: 'ETH', + status: 'confirmed', + }) + }) + + it('returns empty array when no transactions found', async () => { + const mockResponse = { + status: '0', + message: 'No transactions found', + result: [], + } + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + + const provider = new EtherscanProvider(mockEntry, 'ethereum') + const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222') + + expect(txs).toEqual([]) + }) + + it('returns empty array on HTTP error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }) + + const provider = new EtherscanProvider(mockEntry, 'ethereum') + const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222') + + expect(txs).toEqual([]) + }) + + it('returns empty array on fetch error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')) + + const provider = new EtherscanProvider(mockEntry, 'ethereum') + const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222') + + expect(txs).toEqual([]) + }) + + it('marks failed transactions correctly', async () => { + const mockResponse = { + status: '1', + message: 'OK', + result: [ + { + hash: '0xfailed', + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + value: '0', + timeStamp: '1700000000', + isError: '1', + blockNumber: '18000000', + }, + ], + } + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + + const provider = new EtherscanProvider(mockEntry, 'ethereum') + const txs = await provider.getTransactionHistory('0x2222222222222222222222222222222222222222') + + expect(txs[0].status).toBe('failed') + }) + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/integration.test.ts b/src/services/chain-adapter/providers/__tests__/integration.test.ts new file mode 100644 index 00000000..0d91dfdf --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/integration.test.ts @@ -0,0 +1,126 @@ +/** + * 集成测试:验证 ChainProvider 创建流程 + * + * 测试从 chainConfigService → createChainProvider → getTransactionHistory 的完整流程 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createChainProvider, getChainProvider, clearProviderCache } from '../index' +import type { ChainConfig } from '@/services/chain-config' + +// Mock chainConfigStore +const mockGetChainById = vi.fn() +vi.mock('@/stores/chain-config', () => ({ + chainConfigStore: { + state: {}, + }, + chainConfigSelectors: { + getChainById: () => mockGetChainById(), + }, +})) + +describe('ChainProvider 集成测试', () => { + beforeEach(() => { + vi.clearAllMocks() + clearProviderCache() + }) + + it('为 Ethereum 链创建正确的 providers', () => { + const mockEthConfig: ChainConfig = { + id: 'ethereum', + version: '1.0', + type: 'evm', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + enabled: true, + source: 'default', + api: { + 'ethereum-rpc': 'https://ethereum-rpc.publicnode.com', + 'etherscan-v2': 'https://api.etherscan.io/v2/api', + }, + } + mockGetChainById.mockReturnValue(mockEthConfig) + + const provider = createChainProvider('ethereum') + + // 应该有 4 个 providers: + // 1. EtherscanProvider (etherscan-v2) + // 2. EvmRpcProvider (ethereum-rpc) + // 3. WrappedTransactionProvider + // 4. WrappedIdentityProvider + const providers = provider.getProviders() + expect(providers.length).toBeGreaterThanOrEqual(2) + + // 检查是否有 etherscan provider + const etherscanProvider = providers.find(p => p.type.includes('etherscan') || p.type.includes('scan')) + expect(etherscanProvider).toBeDefined() + + // 检查能力 + expect(provider.supportsTransactionHistory).toBe(true) + expect(provider.supportsNativeBalance).toBe(true) + }) + + it('为 BFMeta 链创建正确的 providers', () => { + const mockBfmetaConfig: ChainConfig = { + id: 'bfmeta', + version: '1.0', + type: 'bioforest', + name: 'BFMeta', + symbol: 'BFM', + decimals: 8, + prefix: 'b', + enabled: true, + source: 'default', + api: { + 'biowallet-v1': ['https://walletapi.bfmeta.info', { path: 'bfmeta' }], + }, + } + mockGetChainById.mockReturnValue(mockBfmetaConfig) + + const provider = createChainProvider('bfmeta') + + const providers = provider.getProviders() + expect(providers.length).toBeGreaterThanOrEqual(1) + + // 检查是否有 biowallet provider + const biowalletProvider = providers.find(p => p.type.includes('biowallet')) + expect(biowalletProvider).toBeDefined() + + // 检查能力 + expect(provider.supportsTransactionHistory).toBe(true) + expect(provider.supportsNativeBalance).toBe(true) + }) + + it('当链配置不存在时返回空 providers', () => { + mockGetChainById.mockReturnValue(null) + + const provider = createChainProvider('unknown-chain') + + const providers = provider.getProviders() + // 没有 API providers,但可能有 wrapped providers(取决于实现) + expect(provider.supportsTransactionHistory).toBe(false) + }) + + it('getChainProvider 缓存正常工作', () => { + const mockConfig: ChainConfig = { + id: 'test', + version: '1.0', + type: 'evm', + name: 'Test', + symbol: 'TEST', + decimals: 18, + enabled: true, + source: 'default', + api: { + 'ethereum-rpc': 'https://rpc.test.com', + }, + } + mockGetChainById.mockReturnValue(mockConfig) + + const provider1 = getChainProvider('test') + const provider2 = getChainProvider('test') + + expect(provider1).toBe(provider2) + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/wrapped-identity-provider.test.ts b/src/services/chain-adapter/providers/__tests__/wrapped-identity-provider.test.ts new file mode 100644 index 00000000..d2feee6d --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/wrapped-identity-provider.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { WrappedIdentityProvider } from '../wrapped-identity-provider' +import type { IIdentityService } from '../../types' + +// Mock service +function createMockIdentityService(): IIdentityService { + return { + deriveAddress: vi.fn(), + deriveAddresses: vi.fn(), + isValidAddress: vi.fn(), + normalizeAddress: vi.fn(), + signMessage: vi.fn(), + verifyMessage: vi.fn(), + } +} + +describe('WrappedIdentityProvider', () => { + let identityService: IIdentityService + let provider: WrappedIdentityProvider + + beforeEach(() => { + identityService = createMockIdentityService() + provider = new WrappedIdentityProvider('wrapped-test-identity', identityService) + }) + + describe('constructor', () => { + it('sets type and empty endpoint', () => { + expect(provider.type).toBe('wrapped-test-identity') + expect(provider.endpoint).toBe('') + }) + }) + + describe('deriveAddress', () => { + it('delegates to identityService with default index', async () => { + vi.mocked(identityService.deriveAddress).mockResolvedValue('0x123abc') + + const seed = new Uint8Array(64) + const result = await provider.deriveAddress(seed) + + expect(identityService.deriveAddress).toHaveBeenCalledWith(seed, 0) + expect(result).toBe('0x123abc') + }) + + it('delegates to identityService with specified index', async () => { + vi.mocked(identityService.deriveAddress).mockResolvedValue('0x456def') + + const seed = new Uint8Array(64) + const result = await provider.deriveAddress(seed, 5) + + expect(identityService.deriveAddress).toHaveBeenCalledWith(seed, 5) + expect(result).toBe('0x456def') + }) + }) + + describe('deriveAddresses', () => { + it('delegates to identityService', async () => { + const mockAddresses = ['0x1', '0x2', '0x3'] + vi.mocked(identityService.deriveAddresses).mockResolvedValue(mockAddresses) + + const seed = new Uint8Array(64) + const result = await provider.deriveAddresses(seed, 0, 3) + + expect(identityService.deriveAddresses).toHaveBeenCalledWith(seed, 0, 3) + expect(result).toEqual(mockAddresses) + }) + }) + + describe('isValidAddress', () => { + it('delegates to identityService and returns true for valid address', () => { + vi.mocked(identityService.isValidAddress).mockReturnValue(true) + + const result = provider.isValidAddress('0x123') + + expect(identityService.isValidAddress).toHaveBeenCalledWith('0x123') + expect(result).toBe(true) + }) + + it('delegates to identityService and returns false for invalid address', () => { + vi.mocked(identityService.isValidAddress).mockReturnValue(false) + + const result = provider.isValidAddress('invalid') + + expect(identityService.isValidAddress).toHaveBeenCalledWith('invalid') + expect(result).toBe(false) + }) + }) + + describe('normalizeAddress', () => { + it('delegates to identityService', () => { + vi.mocked(identityService.normalizeAddress).mockReturnValue('0x123abc') + + const result = provider.normalizeAddress('0x123ABC') + + expect(identityService.normalizeAddress).toHaveBeenCalledWith('0x123ABC') + expect(result).toBe('0x123abc') + }) + }) +}) diff --git a/src/services/chain-adapter/providers/__tests__/wrapped-transaction-provider.test.ts b/src/services/chain-adapter/providers/__tests__/wrapped-transaction-provider.test.ts new file mode 100644 index 00000000..66ada700 --- /dev/null +++ b/src/services/chain-adapter/providers/__tests__/wrapped-transaction-provider.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { WrappedTransactionProvider } from '../wrapped-transaction-provider' +import type { ITransactionService, IAssetService, Balance, FeeEstimate, UnsignedTransaction, SignedTransaction, TransactionStatus, Transaction } from '../../types' +import { Amount } from '@/types/amount' + +// Mock services +function createMockTransactionService(): ITransactionService { + return { + estimateFee: vi.fn(), + buildTransaction: vi.fn(), + signTransaction: vi.fn(), + broadcastTransaction: vi.fn(), + getTransactionStatus: vi.fn(), + getTransaction: vi.fn(), + getTransactionHistory: vi.fn(), + } +} + +function createMockAssetService(): IAssetService { + return { + getNativeBalance: vi.fn(), + getTokenBalance: vi.fn(), + getTokenBalances: vi.fn(), + getTokenMetadata: vi.fn(), + } +} + +describe('WrappedTransactionProvider', () => { + let transactionService: ITransactionService + let assetService: IAssetService + let provider: WrappedTransactionProvider + + beforeEach(() => { + transactionService = createMockTransactionService() + assetService = createMockAssetService() + provider = new WrappedTransactionProvider('wrapped-test-tx', transactionService, assetService) + }) + + describe('constructor', () => { + it('sets type and empty endpoint', () => { + expect(provider.type).toBe('wrapped-test-tx') + expect(provider.endpoint).toBe('') + }) + }) + + describe('getNativeBalance', () => { + it('delegates to assetService.getNativeBalance', async () => { + const mockBalance: Balance = { + amount: Amount.fromRaw('1000000', 8, 'TEST'), + symbol: 'TEST', + } + vi.mocked(assetService.getNativeBalance).mockResolvedValue(mockBalance) + + const result = await provider.getNativeBalance('0x123') + + expect(assetService.getNativeBalance).toHaveBeenCalledWith('0x123') + expect(result).toEqual(mockBalance) + }) + }) + + describe('getTransactionHistory', () => { + it('delegates to transactionService and converts transactions', async () => { + const mockTxs: Transaction[] = [{ + hash: '0xabc', + from: '0x1', + to: '0x2', + value: '1000', + symbol: 'TEST', + timestamp: 1234567890, + status: 'confirmed', + blockNumber: 100n, + }] + vi.mocked(transactionService.getTransactionHistory).mockResolvedValue(mockTxs) + + const result = await provider.getTransactionHistory('0x123', 10) + + expect(transactionService.getTransactionHistory).toHaveBeenCalledWith('0x123', 10) + expect(result).toHaveLength(1) + expect(result[0].hash).toBe('0xabc') + }) + }) + + describe('getTransaction', () => { + it('delegates to transactionService and converts transaction', async () => { + const mockTx: Transaction = { + hash: '0xabc', + from: '0x1', + to: '0x2', + value: '1000', + symbol: 'TEST', + timestamp: 1234567890, + status: 'confirmed', + } + vi.mocked(transactionService.getTransaction).mockResolvedValue(mockTx) + + const result = await provider.getTransaction('0xabc') + + expect(transactionService.getTransaction).toHaveBeenCalledWith('0xabc') + expect(result?.hash).toBe('0xabc') + }) + + it('returns null when transaction not found', async () => { + vi.mocked(transactionService.getTransaction).mockResolvedValue(null) + + const result = await provider.getTransaction('0xnotfound') + + expect(result).toBeNull() + }) + }) + + describe('getTransactionStatus', () => { + it('delegates to transactionService and converts status', async () => { + const mockStatus: TransactionStatus = { + status: 'confirmed', + confirmations: 10, + requiredConfirmations: 6, + } + vi.mocked(transactionService.getTransactionStatus).mockResolvedValue(mockStatus) + + const result = await provider.getTransactionStatus('0xabc') + + expect(transactionService.getTransactionStatus).toHaveBeenCalledWith('0xabc') + expect(result.status).toBe('confirmed') + expect(result.confirmations).toBe(10) + }) + }) + + describe('estimateFee', () => { + it('delegates to transactionService and converts fee estimate', async () => { + const mockFeeEstimate: FeeEstimate = { + slow: { amount: Amount.fromRaw('1000', 8, 'TEST'), estimatedTime: 600 }, + standard: { amount: Amount.fromRaw('2000', 8, 'TEST'), estimatedTime: 180 }, + fast: { amount: Amount.fromRaw('3000', 8, 'TEST'), estimatedTime: 30 }, + } + vi.mocked(transactionService.estimateFee).mockResolvedValue(mockFeeEstimate) + + const params = { + from: '0x1', + to: '0x2', + amount: Amount.fromRaw('10000', 8, 'TEST'), + } + const result = await provider.estimateFee(params) + + expect(transactionService.estimateFee).toHaveBeenCalledWith(params) + expect(result.slow.amount.toRawString()).toBe('1000') + expect(result.standard.amount.toRawString()).toBe('2000') + expect(result.fast.amount.toRawString()).toBe('3000') + }) + }) + + describe('buildTransaction', () => { + it('delegates to transactionService', async () => { + const mockUnsignedTx: UnsignedTransaction = { + chainId: 'test', + data: { type: 'transfer' }, + } + vi.mocked(transactionService.buildTransaction).mockResolvedValue(mockUnsignedTx) + + const params = { + from: '0x1', + to: '0x2', + amount: Amount.fromRaw('10000', 8, 'TEST'), + } + const result = await provider.buildTransaction(params) + + expect(transactionService.buildTransaction).toHaveBeenCalledWith(params) + expect(result).toEqual(mockUnsignedTx) + }) + }) + + describe('signTransaction', () => { + it('delegates to transactionService and converts signature', async () => { + const mockSignedTx: SignedTransaction = { + chainId: 'test', + data: { type: 'transfer' }, + signature: '0xsig123', + } + vi.mocked(transactionService.signTransaction).mockResolvedValue(mockSignedTx) + + const unsignedTx: UnsignedTransaction = { + chainId: 'test', + data: { type: 'transfer' }, + } + const privateKey = new Uint8Array(32) + const result = await provider.signTransaction(unsignedTx, privateKey) + + expect(transactionService.signTransaction).toHaveBeenCalledWith(unsignedTx, privateKey) + expect(result.signature).toBe('0xsig123') + }) + }) + + describe('broadcastTransaction', () => { + it('delegates to transactionService', async () => { + vi.mocked(transactionService.broadcastTransaction).mockResolvedValue('0xtxhash') + + const signedTx = { + chainId: 'test', + data: { type: 'transfer' }, + signature: '0xsig', + } + const result = await provider.broadcastTransaction(signedTx) + + expect(transactionService.broadcastTransaction).toHaveBeenCalledWith(signedTx) + expect(result).toBe('0xtxhash') + }) + }) +}) diff --git a/src/services/chain-adapter/providers/biowallet-provider.ts b/src/services/chain-adapter/providers/biowallet-provider.ts new file mode 100644 index 00000000..3a8fcd32 --- /dev/null +++ b/src/services/chain-adapter/providers/biowallet-provider.ts @@ -0,0 +1,166 @@ +/** + * BioWallet API Provider + * + * 提供 BioForest 链的余额和交易历史查询能力。 + */ + +import type { ApiProvider, Balance, Transaction } from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' + +interface BiowalletAssetResponse { + success: boolean + result?: { + address: string + assets: Record> + } +} + +interface BiowalletTxResponse { + success: boolean + result?: { + transactions: Array<{ + signature: string + senderAddress: string + receiverAddress: string + amount: string + assetType: string + timestamp: number + applyBlockHeight: number + }> + } +} + +interface BiowalletBlockResponse { + success: boolean + result?: { height: number } +} + +export class BiowalletProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly path: string + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.path = (entry.config?.path as string) ?? chainId + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + private get baseUrl(): string { + return `${this.endpoint}/wallet/${this.path}` + } + + async getNativeBalance(address: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/address/asset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const json = await response.json() as BiowalletAssetResponse + + if (!json.success || !json.result) { + return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } + } + + // 查找原生代币余额 + for (const magic of Object.values(json.result.assets)) { + for (const asset of Object.values(magic)) { + if (asset.assetType === this.symbol) { + return { + amount: Amount.fromRaw(asset.assetNumber, this.decimals, this.symbol), + symbol: this.symbol, + } + } + } + } + + return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } + } catch (error) { + console.warn('[BiowalletProvider] Error fetching balance:', error) + return { amount: Amount.zero(this.decimals, this.symbol), symbol: this.symbol } + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + try { + // 先获取最新区块高度 + const blockResponse = await fetch(`${this.baseUrl}/lastblock`) + if (!blockResponse.ok) return [] + + const blockJson = await blockResponse.json() as BiowalletBlockResponse + if (!blockJson.success || !blockJson.result) return [] + + const maxHeight = blockJson.result.height + + // 查询交易 + const response = await fetch(`${this.baseUrl}/transactions/query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + maxHeight, + address, + limit, + }), + }) + + if (!response.ok) return [] + + const json = await response.json() as BiowalletTxResponse + + if (!json.success || !json.result?.transactions) return [] + + return json.result.transactions.map((tx): Transaction => ({ + hash: tx.signature, + from: tx.senderAddress, + to: tx.receiverAddress, + value: tx.amount, + symbol: tx.assetType, + timestamp: tx.timestamp, + status: 'confirmed', + blockNumber: BigInt(tx.applyBlockHeight), + })) + } catch (error) { + console.warn('[BiowalletProvider] Error fetching transactions:', error) + return [] + } + } + + async getBlockHeight(): Promise { + try { + const response = await fetch(`${this.baseUrl}/lastblock`) + if (!response.ok) return 0n + + const json = await response.json() as BiowalletBlockResponse + if (!json.success || !json.result) return 0n + + return BigInt(json.result.height) + } catch { + return 0n + } + } +} + +/** 工厂函数 */ +export function createBiowalletProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type.startsWith('biowallet-')) { + return new BiowalletProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/chain-provider.ts b/src/services/chain-adapter/providers/chain-provider.ts new file mode 100644 index 00000000..c5b1c617 --- /dev/null +++ b/src/services/chain-adapter/providers/chain-provider.ts @@ -0,0 +1,166 @@ +/** + * Chain Provider + * + * 聚合多个 ApiProvider,通过能力发现动态代理方法调用。 + */ + +import type { + ApiProvider, + ApiProviderMethod, + Balance, + Transaction, + TransactionStatus, + FeeEstimate, + TransferParams, + UnsignedTransaction, + SignedTransaction, +} from './types' + +export class ChainProvider { + readonly chainId: string + private readonly providers: ApiProvider[] + + constructor(chainId: string, providers: ApiProvider[]) { + this.chainId = chainId + this.providers = providers + } + + /** + * 检查是否有 Provider 支持某方法 + */ + supports(method: ApiProviderMethod): boolean { + return this.providers.some(p => typeof p[method] === 'function') + } + + /** + * 查找实现了某方法的 Provider,返回绑定好的方法 + */ + private getMethod(method: K): ApiProvider[K] | undefined { + const provider = this.providers.find(p => typeof p[method] === 'function') + if (!provider) return undefined + const fn = provider[method] + if (typeof fn !== 'function') return undefined + return fn.bind(provider) as ApiProvider[K] + } + + // ===== 便捷属性:检查查询能力 ===== + + get supportsNativeBalance(): boolean { + return this.supports('getNativeBalance') + } + + get supportsTransactionHistory(): boolean { + return this.supports('getTransactionHistory') + } + + get supportsTransaction(): boolean { + return this.supports('getTransaction') + } + + get supportsBlockHeight(): boolean { + return this.supports('getBlockHeight') + } + + // ===== 便捷属性:检查交易能力 ===== + + get supportsFeeEstimate(): boolean { + return this.supports('estimateFee') + } + + get supportsBuildTransaction(): boolean { + return this.supports('buildTransaction') + } + + get supportsSignTransaction(): boolean { + return this.supports('signTransaction') + } + + get supportsBroadcast(): boolean { + return this.supports('broadcastTransaction') + } + + /** 是否支持完整交易流程 (构建 + 签名 + 广播) */ + get supportsFullTransaction(): boolean { + return this.supportsBuildTransaction && this.supportsSignTransaction && this.supportsBroadcast + } + + // ===== 便捷属性:检查身份能力 ===== + + get supportsDeriveAddress(): boolean { + return this.supports('deriveAddress') + } + + get supportsAddressValidation(): boolean { + return this.supports('isValidAddress') + } + + // ===== 代理方法:查询 ===== + + get getNativeBalance(): ((address: string) => Promise) | undefined { + return this.getMethod('getNativeBalance') + } + + get getTransactionHistory(): ((address: string, limit?: number) => Promise) | undefined { + return this.getMethod('getTransactionHistory') + } + + get getTransaction(): ((hash: string) => Promise) | undefined { + return this.getMethod('getTransaction') + } + + get getTransactionStatus(): ((hash: string) => Promise) | undefined { + return this.getMethod('getTransactionStatus') + } + + get getBlockHeight(): (() => Promise) | undefined { + return this.getMethod('getBlockHeight') + } + + // ===== 代理方法:交易 ===== + + get estimateFee(): ((params: TransferParams) => Promise) | undefined { + return this.getMethod('estimateFee') + } + + get buildTransaction(): ((params: TransferParams) => Promise) | undefined { + return this.getMethod('buildTransaction') + } + + get signTransaction(): ((unsignedTx: UnsignedTransaction, privateKey: Uint8Array) => Promise) | undefined { + return this.getMethod('signTransaction') + } + + get broadcastTransaction(): ((signedTx: SignedTransaction) => Promise) | undefined { + return this.getMethod('broadcastTransaction') + } + + // ===== 代理方法:身份 ===== + + get deriveAddress(): ((seed: Uint8Array, index?: number) => Promise) | undefined { + return this.getMethod('deriveAddress') + } + + get deriveAddresses(): ((seed: Uint8Array, startIndex: number, count: number) => Promise) | undefined { + return this.getMethod('deriveAddresses') + } + + get isValidAddress(): ((address: string) => boolean) | undefined { + return this.getMethod('isValidAddress') + } + + get normalizeAddress(): ((address: string) => string) | undefined { + return this.getMethod('normalizeAddress') + } + + // ===== 工具方法 ===== + + /** 获取所有 Provider */ + getProviders(): readonly ApiProvider[] { + return this.providers + } + + /** 获取指定类型的 Provider */ + getProviderByType(type: string): ApiProvider | undefined { + return this.providers.find(p => p.type === type) + } +} diff --git a/src/services/chain-adapter/providers/etherscan-provider.ts b/src/services/chain-adapter/providers/etherscan-provider.ts new file mode 100644 index 00000000..4f9210d8 --- /dev/null +++ b/src/services/chain-adapter/providers/etherscan-provider.ts @@ -0,0 +1,115 @@ +/** + * Etherscan API Provider + * + * 提供 EVM 链的交易历史查询能力。 + * 支持 Etherscan v2 API (统一接口,通过 chainid 区分链) + */ + +import type { ApiProvider, Transaction } from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' + +/** EVM Chain IDs */ +const EVM_CHAIN_IDS: Record = { + ethereum: 1, + binance: 56, + 'ethereum-sepolia': 11155111, + 'bsc-testnet': 97, +} + +interface EtherscanTx { + hash: string + from: string + to: string + value: string + timeStamp: string + isError: string + blockNumber: string +} + +interface EtherscanResponse { + status: string + message: string + result: EtherscanTx[] | string +} + +export class EtherscanProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly evmChainId: number + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.evmChainId = EVM_CHAIN_IDS[chainId] ?? 1 + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + async getTransactionHistory(address: string, limit = 20): Promise { + try { + const apiKey = (this.config?.apiKey as string) ?? '' + const params = new URLSearchParams({ + chainid: this.evmChainId.toString(), + module: 'account', + action: 'txlist', + address, + startblock: '0', + endblock: '99999999', + page: '1', + offset: limit.toString(), + sort: 'desc', + }) + + if (apiKey) { + params.set('apikey', apiKey) + } + + const url = `${this.endpoint}?${params.toString()}` + const response = await fetch(url) + + if (!response.ok) { + console.warn(`[EtherscanProvider] HTTP ${response.status}`) + return [] + } + + const json = await response.json() as EtherscanResponse + + if (json.status !== '1' || !Array.isArray(json.result)) { + // status !== '1' 可能是 "No transactions found" 等情况 + return [] + } + + return json.result.map((tx): Transaction => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + value: tx.value, + symbol: this.symbol, + timestamp: parseInt(tx.timeStamp, 10) * 1000, + status: tx.isError === '0' ? 'confirmed' : 'failed', + blockNumber: BigInt(tx.blockNumber), + })) + } catch (error) { + console.warn('[EtherscanProvider] Error fetching transactions:', error) + return [] + } + } +} + +/** 工厂函数 */ +export function createEtherscanProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + // 匹配 etherscan-*, blockscout-*, 或 *scan-* 类型 + // Blockscout 使用与 Etherscan 兼容的 API 格式 + if (entry.type.includes('etherscan') || entry.type.includes('blockscout') || entry.type.includes('scan')) { + return new EtherscanProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/evm-rpc-provider.ts b/src/services/chain-adapter/providers/evm-rpc-provider.ts new file mode 100644 index 00000000..25e9ce43 --- /dev/null +++ b/src/services/chain-adapter/providers/evm-rpc-provider.ts @@ -0,0 +1,90 @@ +/** + * EVM RPC Provider + * + * 提供 EVM 链的余额查询和区块高度查询能力。 + * 使用标准 Ethereum JSON-RPC API。 + */ + +import type { ApiProvider, Balance } from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' + +export class EvmRpcProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + private async rpc(method: string, params: unknown[] = []): Promise { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method, + params, + }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const json = await response.json() as { result?: T; error?: { message: string } } + if (json.error) { + throw new Error(json.error.message) + } + + return json.result as T + } + + async getNativeBalance(address: string): Promise { + try { + const balanceHex = await this.rpc('eth_getBalance', [address, 'latest']) + const balanceWei = BigInt(balanceHex) + + return { + amount: Amount.fromRaw(balanceWei.toString(), this.decimals, this.symbol), + symbol: this.symbol, + } + } catch (error) { + console.warn('[EvmRpcProvider] Error fetching balance:', error) + return { + amount: Amount.zero(this.decimals, this.symbol), + symbol: this.symbol, + } + } + } + + async getBlockHeight(): Promise { + try { + const blockHex = await this.rpc('eth_blockNumber') + return BigInt(blockHex) + } catch { + return 0n + } + } +} + +/** 工厂函数 */ +export function createEvmRpcProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + // 匹配 *-rpc 类型且是 EVM 链 (ethereum, bsc 等) + if (entry.type.endsWith('-rpc') && (entry.type.includes('ethereum') || entry.type.includes('bsc'))) { + return new EvmRpcProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts new file mode 100644 index 00000000..b18c44a0 --- /dev/null +++ b/src/services/chain-adapter/providers/index.ts @@ -0,0 +1,182 @@ +/** + * Chain Providers + * + * 导出 ChainProvider 和各个 ApiProvider 实现。 + */ + +export * from './types' +export { ChainProvider } from './chain-provider' + +// API Provider 实现 +export { EtherscanProvider, createEtherscanProvider } from './etherscan-provider' +export { EvmRpcProvider, createEvmRpcProvider } from './evm-rpc-provider' +export { BiowalletProvider, createBiowalletProvider } from './biowallet-provider' +export { TronRpcProvider, createTronRpcProvider } from './tron-rpc-provider' +export { MempoolProvider, createMempoolProvider } from './mempool-provider' + +// Wrapped Provider 实现 +export { WrappedTransactionProvider } from './wrapped-transaction-provider' +export { WrappedIdentityProvider } from './wrapped-identity-provider' + +// 工厂函数 +import type { ApiProvider, ApiProviderFactory } from './types' +import type { ParsedApiEntry, ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { ChainProvider } from './chain-provider' + +import { createEtherscanProvider } from './etherscan-provider' +import { createEvmRpcProvider } from './evm-rpc-provider' +import { createBiowalletProvider } from './biowallet-provider' +import { createTronRpcProvider } from './tron-rpc-provider' +import { createMempoolProvider } from './mempool-provider' +import { WrappedTransactionProvider } from './wrapped-transaction-provider' +import { WrappedIdentityProvider } from './wrapped-identity-provider' + +// 按需导入 service 工厂 +import { EvmIdentityService } from '../evm/identity-service' +import { EvmAssetService } from '../evm/asset-service' +import { EvmTransactionService } from '../evm/transaction-service' +import { TronIdentityService } from '../tron/identity-service' +import { TronAssetService } from '../tron/asset-service' +import { TronTransactionService } from '../tron/transaction-service' +import { BitcoinIdentityService } from '../bitcoin/identity-service' +import { BitcoinAssetService } from '../bitcoin/asset-service' +import { BitcoinTransactionService } from '../bitcoin/transaction-service' +import { BioforestIdentityService } from '../bioforest/identity-service' +import { BioforestAssetService } from '../bioforest/asset-service' +import { BioforestTransactionService } from '../bioforest/transaction-service' +import { Bip39IdentityService } from '../bip39/identity-service' +import { Bip39AssetService } from '../bip39/asset-service' +import { Bip39TransactionService } from '../bip39/transaction-service' + +/** 所有 Provider 工厂函数 */ +const PROVIDER_FACTORIES: ApiProviderFactory[] = [ + createBiowalletProvider, + createEtherscanProvider, + createEvmRpcProvider, + createTronRpcProvider, + createMempoolProvider, +] + +/** + * 从配置创建 ApiProvider + */ +function createApiProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + for (const factory of PROVIDER_FACTORIES) { + const provider = factory(entry, chainId) + if (provider) return provider + } + return null +} + +/** + * 创建包装的交易和身份 Provider + */ +function createWrappedProviders(config: ChainConfig): ApiProvider[] { + const providers: ApiProvider[] = [] + + switch (config.type) { + case 'evm': { + const identity = new EvmIdentityService(config.id) + const asset = new EvmAssetService(config.id) + const transaction = new EvmTransactionService(config.id) + providers.push( + new WrappedTransactionProvider(`wrapped-evm-tx`, transaction, asset), + new WrappedIdentityProvider(`wrapped-evm-identity`, identity), + ) + break + } + case 'tron': { + const identity = new TronIdentityService(config) + const asset = new TronAssetService(config.id) + const transaction = new TronTransactionService(config) + providers.push( + new WrappedTransactionProvider(`wrapped-tron-tx`, transaction, asset), + new WrappedIdentityProvider(`wrapped-tron-identity`, identity), + ) + break + } + case 'bitcoin': { + const identity = new BitcoinIdentityService(config) + const asset = new BitcoinAssetService(config.id) + const transaction = new BitcoinTransactionService(config.id) + providers.push( + new WrappedTransactionProvider(`wrapped-bitcoin-tx`, transaction, asset), + new WrappedIdentityProvider(`wrapped-bitcoin-identity`, identity), + ) + break + } + case 'bioforest': { + const identity = new BioforestIdentityService(config) + const asset = new BioforestAssetService(config.id) + const transaction = new BioforestTransactionService(config) + providers.push( + new WrappedTransactionProvider(`wrapped-bioforest-tx`, transaction, asset), + new WrappedIdentityProvider(`wrapped-bioforest-identity`, identity), + ) + break + } + case 'bip39': { + const identity = new Bip39IdentityService(config) + const asset = new Bip39AssetService(config.id) + const transaction = new Bip39TransactionService(config) + providers.push( + new WrappedTransactionProvider(`wrapped-bip39-tx`, transaction, asset), + new WrappedIdentityProvider(`wrapped-bip39-identity`, identity), + ) + break + } + } + + return providers +} + +/** + * 为指定链创建 ChainProvider + * + * 根据链配置中的 api 条目,创建对应的 ApiProvider 并聚合到 ChainProvider。 + * 同时添加包装的交易和身份 Provider。 + */ +export function createChainProvider(chainId: string): ChainProvider { + const entries = chainConfigService.getApi(chainId) + const config = chainConfigService.getConfig(chainId) + const providers: ApiProvider[] = [] + + // 添加 API Providers (查询能力) + for (const entry of entries) { + const provider = createApiProvider(entry, chainId) + if (provider) { + providers.push(provider) + } + } + + // 添加 Wrapped Providers (交易 + 身份能力) + if (config) { + const wrappedProviders = createWrappedProviders(config) + providers.push(...wrappedProviders) + } + + return new ChainProvider(chainId, providers) +} + +/** ChainProvider 缓存 */ +const providerCache = new Map() + +/** + * 获取或创建 ChainProvider(带缓存) + */ +export function getChainProvider(chainId: string): ChainProvider { + let provider = providerCache.get(chainId) + if (!provider) { + provider = createChainProvider(chainId) + providerCache.set(chainId, provider) + } + return provider +} + +/** + * 清除 ChainProvider 缓存 + */ +export function clearProviderCache(): void { + providerCache.clear() +} diff --git a/src/services/chain-adapter/providers/mempool-provider.ts b/src/services/chain-adapter/providers/mempool-provider.ts new file mode 100644 index 00000000..d9ff430f --- /dev/null +++ b/src/services/chain-adapter/providers/mempool-provider.ts @@ -0,0 +1,157 @@ +/** + * Mempool.space API Provider + * + * 提供 Bitcoin 链的余额、交易历史和区块高度查询能力。 + */ + +import type { ApiProvider, Balance, Transaction } from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' + +interface MempoolAddressInfo { + address: string + chain_stats: { + funded_txo_sum: number + spent_txo_sum: number + } + mempool_stats: { + funded_txo_sum: number + spent_txo_sum: number + } +} + +interface MempoolTx { + txid: string + status: { + confirmed: boolean + block_height?: number + block_time?: number + } + vin: Array<{ + prevout?: { + scriptpubkey_address?: string + value: number + } + }> + vout: Array<{ + scriptpubkey_address?: string + value: number + }> +} + +export class MempoolProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + private async api(path: string): Promise { + const response = await fetch(`${this.endpoint}${path}`) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + return response.json() as Promise + } + + async getNativeBalance(address: string): Promise { + try { + const info = await this.api(`/address/${address}`) + + // 计算余额:已收到 - 已花费 + const confirmed = info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum + const unconfirmed = info.mempool_stats.funded_txo_sum - info.mempool_stats.spent_txo_sum + const total = confirmed + unconfirmed + + return { + amount: Amount.fromRaw(total.toString(), this.decimals, this.symbol), + symbol: this.symbol, + } + } catch (error) { + console.warn('[MempoolProvider] Error fetching balance:', error) + return { + amount: Amount.zero(this.decimals, this.symbol), + symbol: this.symbol, + } + } + } + + async getTransactionHistory(address: string, limit = 20): Promise { + try { + const txs = await this.api(`/address/${address}/txs`) + + return txs.slice(0, limit).map((tx): Transaction => { + // 判断是发送还是接收 + const isOutgoing = tx.vin.some(vin => + vin.prevout?.scriptpubkey_address === address + ) + + // 计算金额 + let value = 0n + if (isOutgoing) { + // 发送:计算发送给其他地址的金额 + for (const vout of tx.vout) { + if (vout.scriptpubkey_address && vout.scriptpubkey_address !== address) { + value += BigInt(vout.value) + } + } + } else { + // 接收:计算收到的金额 + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === address) { + value += BigInt(vout.value) + } + } + } + + // 获取对方地址 + const counterparty = isOutgoing + ? tx.vout.find(v => v.scriptpubkey_address !== address)?.scriptpubkey_address ?? '' + : tx.vin[0]?.prevout?.scriptpubkey_address ?? '' + + return { + hash: tx.txid, + from: isOutgoing ? address : counterparty, + to: isOutgoing ? counterparty : address, + value: value.toString(), + symbol: this.symbol, + timestamp: (tx.status.block_time ?? Math.floor(Date.now() / 1000)) * 1000, + status: tx.status.confirmed ? 'confirmed' : 'pending', + blockNumber: tx.status.block_height ? BigInt(tx.status.block_height) : undefined, + } + }) + } catch (error) { + console.warn('[MempoolProvider] Error fetching transactions:', error) + return [] + } + } + + async getBlockHeight(): Promise { + try { + const height = await this.api('/blocks/tip/height') + return BigInt(height) + } catch { + return 0n + } + } +} + +/** 工厂函数 */ +export function createMempoolProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type.startsWith('mempool-')) { + return new MempoolProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/tron-rpc-provider.ts b/src/services/chain-adapter/providers/tron-rpc-provider.ts new file mode 100644 index 00000000..33c1019c --- /dev/null +++ b/src/services/chain-adapter/providers/tron-rpc-provider.ts @@ -0,0 +1,97 @@ +/** + * Tron RPC Provider + * + * 提供 Tron 链的余额和区块高度查询能力。 + */ + +import type { ApiProvider, Balance } from './types' +import type { ParsedApiEntry } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' +import { Amount } from '@/types/amount' + +interface TronAccountResponse { + balance?: number + address?: string +} + +interface TronBlockResponse { + block_header?: { + raw_data?: { + number?: number + } + } +} + +export class TronRpcProvider implements ApiProvider { + readonly type: string + readonly endpoint: string + readonly config?: Record + + private readonly chainId: string + private readonly symbol: string + private readonly decimals: number + + constructor(entry: ParsedApiEntry, chainId: string) { + this.type = entry.type + this.endpoint = entry.endpoint + this.config = entry.config + this.chainId = chainId + this.symbol = chainConfigService.getSymbol(chainId) + this.decimals = chainConfigService.getDecimals(chainId) + } + + private async api(path: string, body?: unknown): Promise { + const url = `${this.endpoint}${path}` + const init: RequestInit = body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : { method: 'GET' } + + const response = await fetch(url, init) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + return response.json() as Promise + } + + async getNativeBalance(address: string): Promise { + try { + // Tron 地址需要转换为 hex 格式或使用 base58 + const account = await this.api('/wallet/getaccount', { + address, + visible: true, + }) + + const balanceSun = account.balance ?? 0 + + return { + amount: Amount.fromRaw(balanceSun.toString(), this.decimals, this.symbol), + symbol: this.symbol, + } + } catch (error) { + console.warn('[TronRpcProvider] Error fetching balance:', error) + return { + amount: Amount.zero(this.decimals, this.symbol), + symbol: this.symbol, + } + } + } + + async getBlockHeight(): Promise { + try { + const block = await this.api('/wallet/getnowblock') + const height = block.block_header?.raw_data?.number ?? 0 + return BigInt(height) + } catch { + return 0n + } + } +} + +/** 工厂函数 */ +export function createTronRpcProvider(entry: ParsedApiEntry, chainId: string): ApiProvider | null { + if (entry.type === 'tron-rpc' || entry.type.startsWith('tron-')) { + return new TronRpcProvider(entry, chainId) + } + return null +} diff --git a/src/services/chain-adapter/providers/types.ts b/src/services/chain-adapter/providers/types.ts new file mode 100644 index 00000000..ad1edbb3 --- /dev/null +++ b/src/services/chain-adapter/providers/types.ts @@ -0,0 +1,138 @@ +/** + * API Provider 类型定义 + * + * 每个 ApiProvider 实现特定 API 的部分能力。 + * ChainProvider 聚合多个 ApiProvider,通过能力发现动态代理方法调用。 + */ + +import type { Amount } from '@/types/amount' +import type { ParsedApiEntry } from '@/services/chain-config' + +// ==================== 数据类型 ==================== + +/** 余额信息 */ +export interface Balance { + amount: Amount + symbol: string +} + +/** 交易信息 */ +export interface Transaction { + hash: string + from: string + to: string + value: string + symbol: string + timestamp: number + status: 'pending' | 'confirmed' | 'failed' + blockNumber?: bigint +} + +/** 手续费选项 */ +export interface Fee { + amount: Amount + estimatedTime: number // seconds +} + +/** 手续费估算结果 */ +export interface FeeEstimate { + slow: Fee + standard: Fee + fast: Fee +} + +/** 转账参数 */ +export interface TransferParams { + from: string + to: string + amount: Amount + memo?: string +} + +/** 未签名交易 */ +export interface UnsignedTransaction { + chainId: string + data: unknown +} + +/** 已签名交易 */ +export interface SignedTransaction { + chainId: string + data: unknown + signature: string +} + +/** 交易状态 */ +export interface TransactionStatus { + status: 'pending' | 'confirming' | 'confirmed' | 'failed' + confirmations: number + requiredConfirmations: number +} + +// ==================== Provider 接口 ==================== + +/** + * API Provider 接口 + * + * 每个方法都是可选的,Provider 只实现它支持的方法。 + * ChainProvider 通过 `supports()` 检查是否有 Provider 实现了某方法。 + */ +export interface ApiProvider { + /** Provider 类型 (来自配置的 key,如 "biowallet-v1") */ + readonly type: string + /** API 端点 (可为空,如 wrapped provider) */ + readonly endpoint: string + /** 额外配置 */ + readonly config?: Record + + // ===== 查询能力 ===== + + /** 查询原生代币余额 */ + getNativeBalance?(address: string): Promise + + /** 查询交易历史 */ + getTransactionHistory?(address: string, limit?: number): Promise + + /** 查询单笔交易 */ + getTransaction?(hash: string): Promise + + /** 获取交易状态 */ + getTransactionStatus?(hash: string): Promise + + /** 获取当前区块高度 */ + getBlockHeight?(): Promise + + // ===== 交易能力 ===== + + /** 估算手续费 */ + estimateFee?(params: TransferParams): Promise + + /** 构建未签名交易 */ + buildTransaction?(params: TransferParams): Promise + + /** 签名交易 */ + signTransaction?(unsignedTx: UnsignedTransaction, privateKey: Uint8Array): Promise + + /** 广播已签名交易 */ + broadcastTransaction?(signedTx: SignedTransaction): Promise + + // ===== 身份能力 ===== + + /** 派生地址 */ + deriveAddress?(seed: Uint8Array, index?: number): Promise + + /** 批量派生地址 */ + deriveAddresses?(seed: Uint8Array, startIndex: number, count: number): Promise + + /** 验证地址格式 */ + isValidAddress?(address: string): boolean + + /** 规范化地址 */ + normalizeAddress?(address: string): string +} + +/** ApiProvider 可调用的方法名 */ +export type ApiProviderMethod = keyof Omit + +/** ApiProvider 工厂函数 */ +export type ApiProviderFactory = (entry: ParsedApiEntry, chainId: string) => ApiProvider | null diff --git a/src/services/chain-adapter/providers/wrapped-identity-provider.ts b/src/services/chain-adapter/providers/wrapped-identity-provider.ts new file mode 100644 index 00000000..d9847552 --- /dev/null +++ b/src/services/chain-adapter/providers/wrapped-identity-provider.ts @@ -0,0 +1,36 @@ +/** + * Wrapped Identity Provider + * + * 包装现有的 IIdentityService,适配 ApiProvider 接口。 + */ + +import type { ApiProvider } from './types' +import type { IIdentityService } from '../types' + +export class WrappedIdentityProvider implements ApiProvider { + readonly type: string + readonly endpoint = '' + + constructor( + type: string, + private readonly identityService: IIdentityService, + ) { + this.type = type + } + + async deriveAddress(seed: Uint8Array, index = 0): Promise { + return this.identityService.deriveAddress(seed, index) + } + + async deriveAddresses(seed: Uint8Array, startIndex: number, count: number): Promise { + return this.identityService.deriveAddresses(seed, startIndex, count) + } + + isValidAddress(address: string): boolean { + return this.identityService.isValidAddress(address) + } + + normalizeAddress(address: string): string { + return this.identityService.normalizeAddress(address) + } +} diff --git a/src/services/chain-adapter/providers/wrapped-transaction-provider.ts b/src/services/chain-adapter/providers/wrapped-transaction-provider.ts new file mode 100644 index 00000000..692b0155 --- /dev/null +++ b/src/services/chain-adapter/providers/wrapped-transaction-provider.ts @@ -0,0 +1,127 @@ +/** + * Wrapped Transaction Provider + * + * 包装现有的 ITransactionService 和 IAssetService,适配 ApiProvider 接口。 + */ + +import type { + ApiProvider, + Balance, + Transaction, + TransactionStatus, + FeeEstimate, + TransferParams, + UnsignedTransaction, + SignedTransaction, + Fee, +} from './types' +import type { + ITransactionService, + IAssetService, + FeeEstimate as AdapterFeeEstimate, + TransactionStatus as AdapterTransactionStatus, + Transaction as AdapterTransaction, +} from '../types' + +export class WrappedTransactionProvider implements ApiProvider { + readonly type: string + readonly endpoint = '' + + constructor( + type: string, + private readonly transactionService: ITransactionService, + private readonly assetService: IAssetService, + ) { + this.type = type + } + + async getNativeBalance(address: string): Promise { + return this.assetService.getNativeBalance(address) + } + + async getTransactionHistory(address: string, limit?: number): Promise { + const txs = await this.transactionService.getTransactionHistory(address, limit) + return txs.map(tx => this.convertTransaction(tx)) + } + + async getTransaction(hash: string): Promise { + const tx = await this.transactionService.getTransaction(hash) + return tx ? this.convertTransaction(tx) : null + } + + async getTransactionStatus(hash: string): Promise { + const status = await this.transactionService.getTransactionStatus(hash) + return this.convertTransactionStatus(status) + } + + async estimateFee(params: TransferParams): Promise { + const estimate = await this.transactionService.estimateFee(params) + return this.convertFeeEstimate(estimate) + } + + async buildTransaction(params: TransferParams): Promise { + return this.transactionService.buildTransaction(params) + } + + async signTransaction(unsignedTx: UnsignedTransaction, privateKey: Uint8Array): Promise { + const signed = await this.transactionService.signTransaction(unsignedTx, privateKey) + return { + chainId: signed.chainId, + data: signed.data, + signature: typeof signed.signature === 'string' ? signed.signature : '', + } + } + + async broadcastTransaction(signedTx: SignedTransaction): Promise { + return this.transactionService.broadcastTransaction(signedTx) + } + + private convertTransaction(tx: AdapterTransaction): Transaction { + return { + hash: tx.hash, + from: tx.from, + to: tx.to ?? '', + value: tx.value, + symbol: tx.symbol, + timestamp: tx.timestamp, + status: tx.status, + blockNumber: tx.blockNumber, + } + } + + private convertTransactionStatus(status: AdapterTransactionStatus): TransactionStatus { + let mappedStatus: TransactionStatus['status'] + switch (status.status) { + case 'pending': + mappedStatus = 'pending' + break + case 'confirmed': + mappedStatus = 'confirmed' + break + case 'failed': + mappedStatus = 'failed' + break + default: + mappedStatus = 'pending' + } + + return { + status: mappedStatus, + confirmations: status.confirmations, + requiredConfirmations: status.requiredConfirmations, + } + } + + private convertFeeEstimate(estimate: AdapterFeeEstimate): FeeEstimate { + const toFee = (amount: AdapterFeeEstimate['standard'], estimatedTime: number): Fee => ({ + amount: amount.amount, + estimatedTime, + }) + + return { + slow: toFee(estimate.slow, 600), // 10 min + standard: toFee(estimate.standard, 180), // 3 min + fast: toFee(estimate.fast, 30), // 30 sec + } + } +} 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/chain-service.ts b/src/services/chain-adapter/tron/chain-service.ts index 2a6f9c4c..338559e9 100644 --- a/src/services/chain-adapter/tron/chain-service.ts +++ b/src/services/chain-adapter/tron/chain-service.ts @@ -3,17 +3,14 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types' import { Amount } from '@/types/amount' import { ChainServiceError, ChainErrorCodes } from '../types' import type { TronBlock, TronAccountResource } 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', -} +/** Default Tron RPC 端点 (fallback) */ +const DEFAULT_RPC_URL = 'https://api.trongrid.io' export class TronChainService implements IChainService { private readonly config: ChainConfig @@ -21,7 +18,8 @@ export class TronChainService implements IChainService { constructor(config: ChainConfig) { this.config = config - this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['tron']! + // 使用 *-rpc API 配置 + this.rpcUrl = chainConfigService.getRpcUrl(config.id) || DEFAULT_RPC_URL } private async api(endpoint: string, body?: unknown): Promise { 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/tron/transaction-service.ts b/src/services/chain-adapter/tron/transaction-service.ts index 76da3961..502597bb 100644 --- a/src/services/chain-adapter/tron/transaction-service.ts +++ b/src/services/chain-adapter/tron/transaction-service.ts @@ -1,10 +1,11 @@ /** * Tron Transaction Service * - * Uses Tron HTTP API via PublicNode (tron-rpc.publicnode.com) + * Uses Tron HTTP API via TronGrid */ import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { ITransactionService, TransferParams, @@ -27,12 +28,8 @@ import type { TronBlock, } 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', -} +/** Default Tron RPC 端点 (fallback) */ +const DEFAULT_RPC_URL = 'https://api.trongrid.io' export class TronTransactionService implements ITransactionService { private readonly config: ChainConfig @@ -40,7 +37,8 @@ export class TronTransactionService implements ITransactionService { constructor(config: ChainConfig) { this.config = config - this.rpcUrl = DEFAULT_RPC_URLS[config.id] ?? config.api?.url ?? DEFAULT_RPC_URLS['tron']! + // 使用 *-rpc API 配置 + this.rpcUrl = chainConfigService.getRpcUrl(config.id) || DEFAULT_RPC_URL } private async api(endpoint: string, body?: unknown): Promise { 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..9962920d 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -1,6 +1,7 @@ -export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType } from './types' +export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType, ParsedApiEntry, ApiEntry, ApiConfig } from './types' +export { chainConfigService } from './service' -import { ChainConfigListSchema, ChainConfigSchema, ChainConfigSubscriptionSchema } from './schema' +import { ChainConfigListSchema, ChainConfigSchema, ChainConfigSubscriptionSchema, VersionedChainConfigFileSchema } from './schema' import { fetchSubscription, type FetchSubscriptionResult } from './subscription' import { loadChainConfigs, @@ -9,9 +10,25 @@ import { saveChainConfigs, saveUserPreferences, saveSubscriptionMeta, + loadDefaultVersion, + saveDefaultVersion, } 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' @@ -39,8 +56,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 +94,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 +114,25 @@ 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 +252,48 @@ 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) + 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(), ]) + // 检测旧版数据:storedVersion 为 null 且有存储的配置数据(说明是旧版升级) + const bundledMajor = parseMajorFromSemver(bundledVersion) + const hasStoredData = storedConfigs.length > 0 || Object.keys(enabledMap).length > 0 || subscription !== null + if (storedDefaultVersion === null && bundledMajor >= 2 && hasStoredData) { + throw new ChainConfigMigrationError(storedDefaultVersion, bundledVersion) + } + + // 版本比较: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 +301,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..1abad2e9 100644 --- a/src/services/chain-config/schema.ts +++ b/src/services/chain-config/schema.ts @@ -21,13 +21,27 @@ export const ChainConfigTypeSchema = z.enum(['bioforest', 'evm', 'tron', 'bip39' export const ChainConfigSourceSchema = z.enum(['default', 'subscription', 'manual']) -/** API 提供商配置(可替换的外部依赖) */ -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), -}) +/** + * API 提供商配置 + * + * 格式: Record + * - providerType: "{provider}-{version}" (e.g., "ethereum-rpc", "etherscan-v2", "biowallet-v1") + * - url: 简单 URL 字符串 + * - [url, config]: URL + 额外配置对象 + * + * 示例: + * { + * "ethereum-rpc": "https://ethereum-rpc.publicnode.com", + * "etherscan-v2": ["https://api.etherscan.io/v2/api", { "apiKey": "xxx" }], + * "biowallet-v1": ["https://walletapi.bfmeta.info", { "path": "bfm" }] + * } + */ +export const ApiEntrySchema = z.union([ + z.string().url(), + z.tuple([z.string().url(), z.record(z.string(), z.unknown())]), +]) + +export const ApiConfigSchema = z.record(z.string(), ApiEntrySchema) /** 区块浏览器配置(可替换的外部依赖) */ export const ExplorerConfigSchema = z.object({ @@ -70,6 +84,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..532d9a93 --- /dev/null +++ b/src/services/chain-config/service.ts @@ -0,0 +1,145 @@ +/** + * Chain Config Service + * + * 提供链配置查询的单一入口。 + * 代码只耦合 chainId,通过此服务获取配置。 + */ + +import { chainConfigStore, chainConfigSelectors } from '@/stores/chain-config' +import type { ApiEntry, ChainConfig, ParsedApiEntry } from './types' + +class ChainConfigService { + /** + * 获取链配置 + */ + getConfig(chainId: string): ChainConfig | null { + return chainConfigSelectors.getChainById(chainConfigStore.state, chainId) + } + + /** + * 获取链的所有 API 配置 + * 返回解析后的 ParsedApiEntry 数组 + */ + getApi(chainId: string): ParsedApiEntry[] { + const config = this.getConfig(chainId) + if (!config?.api) return [] + + const entries: ParsedApiEntry[] = [] + for (const [type, entry] of Object.entries(config.api)) { + entries.push(this.parseApiEntry(type, entry as ApiEntry)) + } + return entries + } + + /** + * 获取指定类型的 API 配置 + */ + getApiByType(chainId: string, type: string): ParsedApiEntry | null { + const entries = this.getApi(chainId) + return entries.find((e) => e.type === type) ?? null + } + + /** + * 查找匹配模式的 API (例如 "*-rpc" 匹配 "ethereum-rpc", "tron-rpc") + */ + getApiByPattern(chainId: string, pattern: string): ParsedApiEntry | null { + const entries = this.getApi(chainId) + const regex = new RegExp('^' + pattern.replace('*', '.*') + '$') + return entries.find((e) => regex.test(e.type)) ?? null + } + + /** + * 解析 API 配置项 + */ + private parseApiEntry(type: string, entry: ApiEntry): ParsedApiEntry { + if (typeof entry === 'string') { + return { type, endpoint: entry } + } + const [endpoint, config] = entry + return { type, endpoint, config } + } + + // ========== 便捷方法 (用于 Adapter) ========== + + /** + * 获取 RPC URL (匹配 *-rpc 类型的 provider) + */ + getRpcUrl(chainId: string): string { + const api = this.getApiByPattern(chainId, '*-rpc') + return api?.endpoint ?? '' + } + + /** + * 获取 BioWallet API 配置 (匹配 biowallet-* 类型) + */ + getBiowalletApi(chainId: string): { endpoint: string; path: string } | null { + const api = this.getApiByPattern(chainId, 'biowallet-*') + if (!api) return null + const path = (api.config?.path as string) ?? chainId + return { endpoint: api.endpoint, path } + } + + /** + * 获取 Etherscan API (匹配 etherscan-* 或 *scan-* 类型) + */ + getEtherscanApi(chainId: string): string | null { + // 先尝试 etherscan-*,再尝试 *scan-* + const api = this.getApiByPattern(chainId, 'etherscan-*') + ?? this.getApiByPattern(chainId, '*scan-*') + return api?.endpoint ?? null + } + + /** + * 获取 Mempool API (匹配 mempool-* 类型) + */ + getMempoolApi(chainId: string): string | null { + const api = this.getApiByPattern(chainId, 'mempool-*') + return api?.endpoint ?? null + } + + /** + * 获取链的 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 +} diff --git a/src/services/chain-config/types.ts b/src/services/chain-config/types.ts index 795663f3..d8fa4f5b 100644 --- a/src/services/chain-config/types.ts +++ b/src/services/chain-config/types.ts @@ -7,6 +7,8 @@ import type { z } from 'zod' import { + ApiConfigSchema, + ApiEntrySchema, ChainConfigListSchema, ChainConfigSchema, ChainConfigSourceSchema, @@ -22,3 +24,15 @@ export type ChainConfigSource = z.infer export type ChainConfig = z.infer export type ChainConfigList = z.infer export type ChainConfigSubscription = z.infer + +/** API 配置项 (url | [url, config]) */ +export type ApiEntry = z.infer +/** API 配置对象 Record */ +export type ApiConfig = z.infer + +/** 解析后的 API 条目,用于 Provider 初始化 */ +export interface ParsedApiEntry { + type: string + endpoint: string + config?: Record +} diff --git a/src/services/ecosystem/handlers/transaction.ts b/src/services/ecosystem/handlers/transaction.ts index 26260c19..e419f402 100644 --- a/src/services/ecosystem/handlers/transaction.ts +++ b/src/services/ecosystem/handlers/transaction.ts @@ -11,18 +11,11 @@ import { HandlerContext, type MiniappInfo, type SignTransactionParams } from './ import { Amount } from '@/types/amount' import { chainConfigActions, chainConfigSelectors, chainConfigStore, walletStore } from '@/stores' -import { getAdapterRegistry, setupAdapters } from '@/services/chain-adapter' +import { getChainProvider } from '@/services/chain-adapter/providers' import { hexToBytes } from '@noble/hashes/utils.js' import { deriveKey } from '@/lib/crypto/derivation' import { createBioforestKeypair, publicKeyToBioforestAddress } from '@/lib/crypto' -let adaptersInitialized = false -function ensureAdapters(): void { - if (adaptersInitialized) return - setupAdapters() - adaptersInitialized = true -} - function findWalletIdByAddress(chainId: string, address: string): string | null { const wallets = walletStore.state.wallets const isHexLike = address.startsWith('0x') @@ -80,14 +73,12 @@ export const handleCreateTransaction: MethodHandler = async (params, _context) = const chainConfig = await getChainConfigOrThrow(opts.chain) const amount = Amount.parse(opts.amount, chainConfig.decimals, chainConfig.symbol) - ensureAdapters() - const registry = getAdapterRegistry() - const adapter = registry.getAdapter(chainConfig.id) - if (!adapter) { - throw Object.assign(new Error(`No adapter for chain: ${chainConfig.id}`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) + const chainProvider = getChainProvider(chainConfig.id) + if (!chainProvider.supportsBuildTransaction) { + throw Object.assign(new Error(`Chain ${chainConfig.id} does not support transaction building`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) } - const unsignedTx = await adapter.transaction.buildTransaction({ + const unsignedTx = await chainProvider.buildTransaction!({ from: opts.from, to: opts.to, amount, @@ -153,11 +144,9 @@ export async function signUnsignedTransaction(params: { }): Promise { const chainConfig = await getChainConfigOrThrow(params.chainId) - ensureAdapters() - const registry = getAdapterRegistry() - const adapter = registry.getAdapter(chainConfig.id) - if (!adapter) { - throw Object.assign(new Error(`No adapter for chain: ${chainConfig.id}`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) + const chainProvider = getChainProvider(chainConfig.id) + if (!chainProvider.supportsSignTransaction) { + throw Object.assign(new Error(`Chain ${chainConfig.id} does not support transaction signing`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) } // Derive chain private key from mnemonic/arbitrary secret. @@ -181,7 +170,7 @@ export async function signUnsignedTransaction(params: { throw Object.assign(new Error(`Unsupported chain type: ${chainConfig.type}`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) } - const signed = await adapter.transaction.signTransaction( + const signed = await chainProvider.signTransaction!( { chainId: params.unsignedTx.chainId, data: params.unsignedTx.data }, privateKeyBytes, ) 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/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..a75fb06b 100644 --- a/src/services/wallet-storage/service.ts +++ b/src/services/wallet-storage/service.ts @@ -1,5 +1,13 @@ -import { openDB, type DBSchema, type IDBPDatabase } from 'idb' -import { encrypt, decrypt, encryptWithRawKey, decryptWithRawKey, deriveEncryptionKeyFromMnemonic, deriveEncryptionKeyFromSecret } from '@/lib/crypto' +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, @@ -10,78 +18,80 @@ import { WALLET_STORAGE_VERSION, WalletStorageError, WalletStorageErrorCode, -} from './types' + 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 @@ -91,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.', + ); } } @@ -130,22 +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() - return (await this.db!.get('walleter', 'main')) ?? null + 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; + } + return result.data as WalleterInfo; } // ==================== 钱包管理 ==================== @@ -154,333 +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() - return (await this.db!.get('wallets', walletId)) ?? null + 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; + } + return result.data as WalletInfo; } /** 获取所有钱包 */ async getAllWallets(): Promise { - this.ensureInitialized() - return this.db!.getAll('wallets') + 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, + ); } } @@ -488,120 +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() - return (await this.db!.get('chainAddresses', addressKey)) ?? null + 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; + } + return result.data as ChainAddressInfo; } /** 获取钱包的所有链地址 */ async getWalletChainAddresses(walletId: string): Promise { - this.ensureInitialized() - return this.db!.getAllFromIndex('chainAddresses', 'by-wallet', walletId) + 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() - return this.db!.getAllFromIndex('chainAddresses', 'by-chain', chain) + 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) { ... } @@ -609,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) { @@ -645,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, @@ -660,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', @@ -677,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/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..e2a73201 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(); @@ -128,6 +131,16 @@ export function WalletTab() { push("WalletListJob", {}); }, [push]); + // 地址余额查询 + const handleOpenAddressBalance = useCallback(() => { + push("AddressBalanceActivity", {}); + }, [push]); + + // 地址交易查询 + const handleOpenAddressTransactions = useCallback(() => { + push("AddressTransactionsActivity", {}); + }, [push]); + // 交易点击 const handleTransactionClick = useCallback( (tx: TransactionInfo) => { @@ -138,6 +151,11 @@ export function WalletTab() { [push] ); + // 需要迁移数据库 + if (migrationRequired) { + return ; + } + if (!isInitialized) { return (
@@ -166,6 +184,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, 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/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 86e5e1bd..4fb388da 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' @@ -50,4 +51,5 @@ export { useHasWallet, useWalletLoading, useWalletInitialized, + useWalletMigrationRequired, } from './hooks' diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index a4d724b0..b3141d39 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, @@ -474,33 +489,32 @@ export const walletActions = { try { // 动态导入避免循环依赖 - const { getAdapterRegistry } = await import('@/services/chain-adapter') - const registry = getAdapterRegistry() - const adapter = registry.getAdapter(chain) + const { getChainProvider } = await import('@/services/chain-adapter/providers') + const chainProvider = getChainProvider(chain) - if (!adapter) { + if (!chainProvider.supportsNativeBalance) { // This is expected during initialization or for unsupported chains // Only log in development to avoid console noise if (import.meta.env.DEV) { - console.debug(`[refreshBalance] Skipping chain without adapter: ${chain}`) + console.debug(`[refreshBalance] Skipping chain without balance support: ${chain}`) } return } - // 获取余额 - const balances = await adapter.asset.getTokenBalances(chainAddress.address) + // 获取原生代币余额 + const balance = await chainProvider.getNativeBalance!(chainAddress.address) - // 转换为 Token 格式 - const tokens: Token[] = balances.map((b) => ({ - id: `${chain}:${b.symbol}`, - symbol: b.symbol, - name: b.symbol, - balance: b.amount.toFormatted(), + // 转换为 Token 格式 (目前只支持原生代币) + const tokens: Token[] = [{ + id: `${chain}:${balance.symbol}`, + symbol: balance.symbol, + name: balance.symbol, + balance: balance.amount.toFormatted(), fiatValue: 0, // TODO: 对接汇率服务 change24h: 0, - decimals: b.amount.decimals, + decimals: balance.amount.decimals, chain, - })) + }] // 更新 store await walletActions.updateChainAssets(walletId, chain, tokens)