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)