diff --git a/miniapps/teleport/src/App.test.tsx b/miniapps/teleport/src/App.test.tsx index 6223f5066..82cdcdfe5 100644 --- a/miniapps/teleport/src/App.test.tsx +++ b/miniapps/teleport/src/App.test.tsx @@ -236,6 +236,62 @@ describe('Teleport App', () => { expect(screen.getByText(/0 NBT 可用/)).toBeInTheDocument() }) + it('should query balances by portal chain assets when account chain is alias', async () => { + const mockedGetTransmitAssetTypeList = vi.mocked(getTransmitAssetTypeList) + mockedGetTransmitAssetTypeList.mockResolvedValueOnce({ + transmitSupport: { + BFMCHAIN: { + BFM: { + enable: true, + isAirdrop: false, + assetType: 'BFM', + recipientAddress: 'bReceiver', + targetChain: 'BFMETAV2', + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 250 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + }, + }, + }, + }) + + mockBio.request.mockImplementation( + ({ method, params }: { method: string; params?: Array<{ chain?: string; asset?: string }> }) => { + if (method === 'bio_selectAccount') { + return Promise.resolve({ address: 'bSource', chain: 'bfmeta', name: 'Source' }) + } + if (method === 'bio_getBalance') { + return Promise.resolve('99991361') + } + if (method === 'bio_closeSplashScreen') { + return Promise.resolve(null) + } + return Promise.resolve(null) + }, + ) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动 BFMCHAIN 传送门' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: '启动 BFMCHAIN 传送门' })) + + await waitFor(() => { + expect(screen.getByTestId('asset-card-BFM')).toBeInTheDocument() + }) + + expect(mockBio.request).toHaveBeenCalledWith({ + method: 'bio_getBalance', + params: [{ address: 'bSource', chain: 'bfmeta', asset: 'BFM' }], + }) + expect(screen.getByText(/0\.99991361 BFM 可用/)).toBeInTheDocument() + }) + it('should not exclude source address when selecting cross-chain target wallet', async () => { mockBio.request.mockImplementation(({ method, params }: { method: string; params?: Array<{ chain?: string }> }) => { if (method === 'bio_selectAccount') { @@ -284,6 +340,75 @@ describe('Teleport App', () => { }) }) + it('should exclude source address when selecting same-chain target wallet through alias', async () => { + const mockedGetTransmitAssetTypeList = vi.mocked(getTransmitAssetTypeList) + mockedGetTransmitAssetTypeList.mockResolvedValueOnce({ + transmitSupport: { + BFMCHAIN: { + BFM: { + enable: true, + isAirdrop: false, + assetType: 'BFM', + recipientAddress: 'bReceiver', + targetChain: 'BFMCHAIN', + targetAsset: 'BFM', + ratio: { numerator: 1, denominator: 1 }, + transmitDate: { + startDate: '2020-01-01', + endDate: '2030-12-31', + }, + }, + }, + }, + }) + + mockBio.request.mockImplementation(({ method }: { method: string }) => { + if (method === 'bio_selectAccount') { + return Promise.resolve({ address: 'bSource', chain: 'bfmeta', name: 'Source' }) + } + if (method === 'bio_getBalance') { + return Promise.resolve('100000000') + } + if (method === 'bio_pickWallet') { + return Promise.resolve({ address: 'bTarget', chain: 'bfmeta', name: 'Target' }) + } + if (method === 'bio_closeSplashScreen') { + return Promise.resolve(null) + } + return Promise.resolve(null) + }) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: '启动 BFMCHAIN 传送门' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: '启动 BFMCHAIN 传送门' })) + + await waitFor(() => { + expect(screen.getByTestId('asset-card-BFM')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('asset-card-BFM')) + await waitFor(() => { + expect(screen.getByTestId('amount-input')).toBeInTheDocument() + }) + fireEvent.change(screen.getByTestId('amount-input'), { target: { value: '1' } }) + fireEvent.click(screen.getByTestId('next-button')) + await waitFor(() => { + expect(screen.getByTestId('target-button')).toBeInTheDocument() + }) + fireEvent.click(screen.getByTestId('target-button')) + + await waitFor(() => { + expect(mockBio.request).toHaveBeenCalledWith({ + method: 'bio_pickWallet', + params: [{ chain: 'BFMCHAIN', exclude: 'bSource' }], + }) + }) + }) + it('should show sender/receiver addresses on confirm step and remove free-fee badge', async () => { mockBio.request.mockImplementation(({ method, params }: { method: string; params?: Array<{ chain?: string }> }) => { if (method === 'bio_selectAccount') { @@ -485,7 +610,7 @@ describe('Teleport App', () => { mockBio.request.mockImplementation( ({ method, params }: { method: string; params?: Array<{ chain?: string }> }) => { if (method === 'bio_selectAccount') { - return Promise.resolve({ address: '0xSourceBsc', chain: params?.[0]?.chain ?? 'BSC', name: 'Source' }) + return Promise.resolve({ address: '0xSourceBsc', chain: 'binance', name: 'Source' }) } if (method === 'bio_getBalance') { return Promise.resolve('1000000000') diff --git a/miniapps/teleport/src/App.tsx b/miniapps/teleport/src/App.tsx index f9e6c0b81..503720a46 100644 --- a/miniapps/teleport/src/App.tsx +++ b/miniapps/teleport/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { BioAccount, BioSignedTransaction, BioUnsignedTransaction } from '@biochain/bio-sdk'; +import { normalizeChainId, type BioAccount, type BioSignedTransaction, type BioUnsignedTransaction } from '@biochain/bio-sdk'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardTitle, CardDescription } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -212,6 +212,18 @@ const CHAIN_COLORS: Record = { const normalizeInternalChainName = (value: string): InternalChainName => value.toUpperCase() as InternalChainName; +const normalizeTeleportChain = (value: string | null | undefined): string => { + const normalized = value?.trim(); + if (!normalized) return ''; + return normalizeChainId(normalized); +}; + +const isSameTeleportChain = (left: string | null | undefined, right: string | null | undefined): boolean => { + const normalizedLeft = normalizeTeleportChain(left); + const normalizedRight = normalizeTeleportChain(right); + return normalizedLeft.length > 0 && normalizedLeft === normalizedRight; +}; + const normalizeInputAmount = (value: string, decimals: number): string => { const normalized = value.trim(); if (!/^\d+(\.\d+)?$/.test(normalized)) { @@ -312,7 +324,7 @@ export default function App() { if (!assets || !sourceAccount) return []; const sourceChain = selectedSourceChain ?? sourceAccount.chain; return assets - .filter((asset) => asset.chain.toLowerCase() === sourceChain.toLowerCase()) + .filter((asset) => isSameTeleportChain(asset.chain, sourceChain)) .map((asset) => ({ ...asset, balance: formatRawBalance( @@ -365,8 +377,17 @@ export default function App() { }); setSourceAccount(account); const accountChain = account.chain || portalChain; - const chainAssets = (assets ?? []).filter((asset) => asset.chain.toLowerCase() === accountChain.toLowerCase()); + const sourceAssetChain = portalChain; + const chainAssetsByPortal = (assets ?? []).filter( + (asset) => isSameTeleportChain(asset.chain, sourceAssetChain), + ); + const chainAssets = + chainAssetsByPortal.length > 0 + ? chainAssetsByPortal + : (assets ?? []).filter((asset) => isSameTeleportChain(asset.chain, accountChain)); const uniqueAssetTypes = [...new Set(chainAssets.map((asset) => asset.assetType.toUpperCase()))]; + const balanceQueryChain = + normalizeTeleportChain(account.chain) || normalizeTeleportChain(portalChain); if (uniqueAssetTypes.length > 0) { const balanceEntries = await Promise.all( @@ -374,7 +395,7 @@ export default function App() { try { const rawBalance = await bio.request({ method: 'bio_getBalance', - params: [{ address: account.address, chain: account.chain, asset: assetType }], + params: [{ address: account.address, chain: balanceQueryChain, asset: assetType }], }); return [assetType, rawBalance] as const; } catch { @@ -413,7 +434,7 @@ export default function App() { if (!window.bio || !sourceAccount || !selectedAsset) return; setLoading(true); setError(null); - const shouldExcludeSameAddress = sourceAccount.chain.toLowerCase() === selectedAsset.targetChain.toLowerCase(); + const shouldExcludeSameAddress = isSameTeleportChain(sourceAccount.chain, selectedAsset.targetChain); try { const account = await window.bio.request({ method: 'bio_pickWallet', @@ -446,12 +467,12 @@ export default function App() { assetType: selectedAsset.targetAsset, }; - const chainLower = sourceAccount.chain.toLowerCase(); + const sourceChain = normalizeTeleportChain(sourceAccount.chain); const isInternalChain = - chainLower !== 'eth' && - chainLower !== 'bsc' && - chainLower !== 'tron' && - chainLower !== 'trc20'; + sourceChain !== 'ethereum' && + sourceChain !== 'binance' && + sourceChain !== 'tron' && + sourceChain !== 'trc20'; const remark = isInternalChain ? { @@ -492,12 +513,12 @@ export default function App() { // 4. 构造 fromTrJson(根据链类型) // 注意:EVM 需要 raw signed tx 的 hex;TRON/内链需要结构化交易体 const fromTrJson: FromTrJson = {}; - const isTronChain = chainLower === 'tron' || chainLower === 'trc20'; - const isTrc20 = chainLower === 'trc20' || (chainLower === 'tron' && !!selectedAsset.contractAddress); + const isTronChain = sourceChain === 'tron' || sourceChain === 'trc20'; + const isTrc20 = sourceChain === 'trc20' || (sourceChain === 'tron' && !!selectedAsset.contractAddress); - if (chainLower === 'eth') { + if (sourceChain === 'ethereum') { fromTrJson.eth = { signTransData: extractEvmSignedTxData(signedTx.data, 'ETH') }; - } else if (chainLower === 'bsc') { + } else if (sourceChain === 'binance') { fromTrJson.bsc = { signTransData: extractEvmSignedTxData(signedTx.data, 'BSC') }; } else if (isTronChain) { if (isTrc20) { diff --git a/src/services/authorize/dweb.ts b/src/services/authorize/dweb.ts index bbe85206f..2bdea3a99 100644 --- a/src/services/authorize/dweb.ts +++ b/src/services/authorize/dweb.ts @@ -1,6 +1,9 @@ import { dwebServiceWorker, type ServiceWorkerFetchEvent } from '@plaoc/plugins' +import { normalizeChainId } from '@biochain/bio-sdk' import { WALLET_PLAOC_PATH } from './paths' import type { CallerAppInfo, IPlaocAdapter } from './types' +import { getChainProvider } from '@/services/chain-adapter/providers' +import { chainConfigService } from '@/services/chain-config/service' type WireEnvelope = Readonly<{ data: T }> @@ -85,7 +88,7 @@ function parseAssetTypeBalancePayload(signaturedata: string): { return { chainName, senderAddress, assetTypes } } -function computeAssetTypeBalances(req: ReturnType): Record< +async function computeAssetTypeBalances(req: ReturnType): Promise { +>> { if (!req) return {} - // tokens 数据已从 walletStore 移除 - 需要从 chain-provider 获取 - // TODO: 使用 getChainProvider(req.chainName).tokenBalances 获取实时余额 - // 目前返回所有请求的 assetType 的默认值 - + const resolvedChain = normalizeChainId(req.chainName) + const provider = getChainProvider(resolvedChain) + const defaultDecimals = chainConfigService.getDecimals(resolvedChain) const result: Record< string, { @@ -110,13 +112,35 @@ function computeAssetTypeBalances(req: ReturnType = {} - for (const reqAsset of req.assetTypes) { - const wantedAssetType = reqAsset.assetType - result[wantedAssetType] = { - assetType: wantedAssetType, - decimals: 0, - balance: '0', - ...(reqAsset.contractAddress ? { contracts: reqAsset.contractAddress } : {}), + try { + const balances = await provider.allBalances.fetch({ address: req.senderAddress }) + + for (const reqAsset of req.assetTypes) { + const wantedAssetType = reqAsset.assetType + const wantedAssetUpper = wantedAssetType.toUpperCase() + const wantedContract = reqAsset.contractAddress?.trim().toLowerCase() + const matched = balances.find((item) => { + if (wantedContract) { + return (item.contractAddress?.toLowerCase() ?? '') === wantedContract + } + return item.symbol.toUpperCase() === wantedAssetUpper + }) + + result[wantedAssetType] = { + assetType: wantedAssetType, + decimals: matched?.decimals ?? defaultDecimals, + balance: matched?.amount.toRawString() ?? '0', + ...(reqAsset.contractAddress ? { contracts: reqAsset.contractAddress } : {}), + } + } + } catch { + for (const reqAsset of req.assetTypes) { + result[reqAsset.assetType] = { + assetType: reqAsset.assetType, + decimals: defaultDecimals, + balance: '0', + ...(reqAsset.contractAddress ? { contracts: reqAsset.contractAddress } : {}), + } } } @@ -155,7 +179,7 @@ async function handleAuthorizeFetch(event: ServiceWorkerFetchEvent): Promise { } let handleGetBalance: typeof import('../handlers/wallet').handleGetBalance - let mockFetch: ReturnType + let mockNativeFetch: ReturnType + let mockAllBalancesFetch: ReturnType let mockGetChainProvider: ReturnType beforeEach(async () => { @@ -18,10 +19,14 @@ describe('handleGetBalance', () => { vi.resetModules() // Create fresh mocks - mockFetch = vi.fn() + mockNativeFetch = vi.fn() + mockAllBalancesFetch = vi.fn() mockGetChainProvider = vi.fn(() => ({ nativeBalance: { - fetch: mockFetch, + fetch: mockNativeFetch, + }, + allBalances: { + fetch: mockAllBalancesFetch, }, })) @@ -71,7 +76,7 @@ describe('handleGetBalance', () => { describe('successful balance query', () => { it('returns balance from chain provider', async () => { - mockFetch.mockResolvedValue({ + mockNativeFetch.mockResolvedValue({ amount: { toRawString: () => '1000000000' }, }) @@ -82,11 +87,11 @@ describe('handleGetBalance', () => { expect(result).toBe('1000000000') expect(mockGetChainProvider).toHaveBeenCalledWith('bfmeta') - expect(mockFetch).toHaveBeenCalledWith({ address: 'b123456789' }) + expect(mockNativeFetch).toHaveBeenCalledWith({ address: 'b123456789' }) }) it('returns "0" for account with no balance', async () => { - mockFetch.mockResolvedValue({ + mockNativeFetch.mockResolvedValue({ amount: { toRawString: () => '0' }, }) @@ -99,7 +104,7 @@ describe('handleGetBalance', () => { }) it('returns "0" when balance is null', async () => { - mockFetch.mockResolvedValue(null) + mockNativeFetch.mockResolvedValue(null) const result = await handleGetBalance( { address: 'b123', chain: 'bfmeta' }, @@ -112,7 +117,7 @@ describe('handleGetBalance', () => { describe('error handling', () => { it('returns "0" when provider throws error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')) + mockNativeFetch.mockRejectedValue(new Error('Network error')) const result = await handleGetBalance( { address: 'b123', chain: 'bfmeta' }, @@ -123,7 +128,7 @@ describe('handleGetBalance', () => { }) it('returns "0" when amount is undefined', async () => { - mockFetch.mockResolvedValue({ amount: undefined }) + mockNativeFetch.mockResolvedValue({ amount: undefined }) const result = await handleGetBalance( { address: 'b123', chain: 'bfmeta' }, @@ -136,7 +141,7 @@ describe('handleGetBalance', () => { describe('different chains', () => { it('works with ethereum chain', async () => { - mockFetch.mockResolvedValue({ + mockNativeFetch.mockResolvedValue({ amount: { toRawString: () => '5000000000000000000' }, }) @@ -150,7 +155,7 @@ describe('handleGetBalance', () => { }) it('works with tron chain', async () => { - mockFetch.mockResolvedValue({ + mockNativeFetch.mockResolvedValue({ amount: { toRawString: () => '100000000' }, }) @@ -162,5 +167,122 @@ describe('handleGetBalance', () => { expect(result).toBe('100000000') expect(mockGetChainProvider).toHaveBeenCalledWith('tron') }) + + it('normalizes API chain alias before querying provider', async () => { + mockNativeFetch.mockResolvedValue({ + amount: { toRawString: () => '5000000000000000000' }, + }) + + const result = await handleGetBalance( + { address: '0x1234567890abcdef', chain: 'BSC' }, + mockContext + ) + + expect(result).toBe('5000000000000000000') + expect(mockGetChainProvider).toHaveBeenCalledWith('binance') + }) + }) + + describe('asset-based balance query', () => { + it('returns specific asset raw balance when asset is provided', async () => { + mockAllBalancesFetch.mockResolvedValue([ + { + symbol: 'USDT', + decimals: 18, + amount: { toRawString: () => '123000000000000000000' }, + }, + ]) + + const result = await handleGetBalance( + { address: '0x123', chain: 'bsc', asset: 'USDT' }, + mockContext + ) + + expect(result).toBe('123000000000000000000') + expect(mockAllBalancesFetch).toHaveBeenCalledWith({ address: '0x123' }) + }) + + it('returns requested assets map for assets[] batch mode', async () => { + mockAllBalancesFetch.mockResolvedValue([ + { + symbol: 'USDT', + contractAddress: '0x55d398326f99059ff775485246999027b3197955', + decimals: 18, + amount: { toRawString: () => '1000000000000000000' }, + }, + { + symbol: 'BSC', + decimals: 18, + amount: { toRawString: () => '2000000000000000000' }, + }, + ]) + + const result = await handleGetBalance( + { + address: '0xabc', + chain: 'bsc', + assets: [ + { + assetType: 'USDT', + contractAddress: '0x55d398326f99059ff775485246999027b3197955', + }, + { assetType: 'BSC' }, + { assetType: 'MISSING' }, + ], + }, + mockContext + ) + + expect(result).toEqual({ + USDT: { + assetType: 'USDT', + decimals: 18, + balance: '1000000000000000000', + contracts: '0x55d398326f99059ff775485246999027b3197955', + contractAddress: '0x55d398326f99059ff775485246999027b3197955', + }, + BSC: { + assetType: 'BSC', + decimals: 18, + balance: '2000000000000000000', + }, + MISSING: { + assetType: 'MISSING', + decimals: 18, + balance: '0', + }, + }) + }) + + it('returns zeroed map when batch provider request fails', async () => { + mockAllBalancesFetch.mockRejectedValue(new Error('Network error')) + + const result = await handleGetBalance( + { + address: '0xabc', + chain: 'bsc', + assets: [ + { assetType: 'USDT', contractAddress: '0x55d398326f99059ff775485246999027b3197955' }, + { assetType: 'BSC' }, + ], + }, + mockContext + ) + + expect(result).toEqual({ + USDT: { + assetType: 'USDT', + decimals: 18, + balance: '0', + contracts: '0x55d398326f99059ff775485246999027b3197955', + contractAddress: '0x55d398326f99059ff775485246999027b3197955', + }, + BSC: { + assetType: 'BSC', + decimals: 18, + balance: '0', + }, + }) + }) }) }) diff --git a/src/services/ecosystem/handlers/wallet.ts b/src/services/ecosystem/handlers/wallet.ts index 668e58f17..4754a8e22 100644 --- a/src/services/ecosystem/handlers/wallet.ts +++ b/src/services/ecosystem/handlers/wallet.ts @@ -4,9 +4,12 @@ import type { MethodHandler, BioAccount } from '../types' import { BioErrorCodes } from '../types' +import { normalizeChainId } from '@biochain/bio-sdk' import { HandlerContext } from './context' import { enqueueMiniappSheet } from '../sheet-queue' import { getChainProvider } from '@/services/chain-adapter/providers' +import { chainConfigService } from '@/services/chain-config/service' +import { chainConfigActions, chainConfigStore } from '@/stores/chain-config' import { walletStore } from '@/stores/wallet' // 兼容旧 API,逐步迁移到 HandlerContext @@ -121,25 +124,111 @@ export const handleChainId: MethodHandler = async (_params, _context) => { /** bio_getBalance - Get balance */ export const handleGetBalance: MethodHandler = async (params, _context) => { - const opts = params as { address?: string; chain?: string; asset?: string } | undefined + const opts = params as + | { + address?: string + chain?: string + asset?: string + assets?: Array<{ assetType?: string; contractAddress?: string }> + } + | undefined if (!opts?.address || !opts?.chain) { throw Object.assign(new Error('Missing address or chain'), { code: BioErrorCodes.INVALID_PARAMS }) } - const provider = getChainProvider(opts.chain) + const resolvedChain = normalizeChainId(opts.chain) const wantedAsset = opts.asset?.trim().toUpperCase() + const wantedAssets = Array.isArray(opts.assets) ? opts.assets : [] + + if (!chainConfigStore.state.snapshot) { + await chainConfigActions.initialize() + } + + const provider = getChainProvider(resolvedChain) + + const buildBatchFallback = () => { + const defaultDecimals = chainConfigService.getDecimals(resolvedChain) + const fallback: Record< + string, + { + assetType: string + decimals: number + balance: string + contracts?: string + contractAddress?: string + } + > = {} + + for (const item of wantedAssets) { + const requestedAssetType = item.assetType?.trim().toUpperCase() + if (!requestedAssetType) continue + fallback[requestedAssetType] = { + assetType: requestedAssetType, + decimals: defaultDecimals, + balance: '0', + ...(item.contractAddress + ? { contracts: item.contractAddress, contractAddress: item.contractAddress } + : {}), + } + } + return fallback + } try { + if (wantedAssets.length > 0) { + const balances = await provider.allBalances.fetch({ address: opts.address }) + const defaultDecimals = chainConfigService.getDecimals(resolvedChain) + + const result: Record< + string, + { + assetType: string + decimals: number + balance: string + contracts?: string + contractAddress?: string + } + > = {} + + for (const item of wantedAssets) { + const requestedAssetType = item.assetType?.trim().toUpperCase() + if (!requestedAssetType) continue + const requestedContract = item.contractAddress?.trim().toLowerCase() + + const matched = balances.find((balanceItem) => { + if (requestedContract) { + return (balanceItem.contractAddress?.toLowerCase() ?? '') === requestedContract + } + return balanceItem.symbol.toUpperCase() === requestedAssetType + }) + + result[requestedAssetType] = { + assetType: requestedAssetType, + decimals: matched?.decimals ?? defaultDecimals, + balance: matched?.amount.toRawString() ?? '0', + ...(item.contractAddress + ? { contracts: item.contractAddress, contractAddress: item.contractAddress } + : {}), + } + } + return result + } + if (wantedAsset) { const balances = await provider.allBalances.fetch({ address: opts.address }) const matched = balances.find((item) => item.symbol.toUpperCase() === wantedAsset) - return matched?.amount.toRawString() ?? '0' + const raw = matched?.amount.toRawString() ?? '0' + return raw } // 使用 ChainProvider 的 nativeBalance fetcher const balance = await provider.nativeBalance.fetch({ address: opts.address }) - return balance?.amount.toRawString() ?? '0' + const raw = balance?.amount.toRawString() ?? '0' + return raw } catch { + if (wantedAssets.length > 0) { + return buildBatchFallback() + } return '0' } }