Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion miniapps/teleport/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />, { 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') {
Expand Down Expand Up @@ -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(<App />, { 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') {
Expand Down Expand Up @@ -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')
Expand Down
49 changes: 35 additions & 14 deletions miniapps/teleport/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -212,6 +212,18 @@ const CHAIN_COLORS: Record<string, string> = {
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)) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -365,16 +377,25 @@ 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(
uniqueAssetTypes.map(async (assetType) => {
try {
const rawBalance = await bio.request<string>({
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 {
Expand Down Expand Up @@ -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<BioAccount>({
method: 'bio_pickWallet',
Expand Down Expand Up @@ -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
? {
Expand Down Expand Up @@ -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) {
Expand Down
52 changes: 38 additions & 14 deletions src/services/authorize/dweb.ts
Original file line number Diff line number Diff line change
@@ -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<T> = Readonly<{ data: T }>

Expand Down Expand Up @@ -85,21 +88,20 @@ function parseAssetTypeBalancePayload(signaturedata: string): {
return { chainName, senderAddress, assetTypes }
}

function computeAssetTypeBalances(req: ReturnType<typeof parseAssetTypeBalancePayload>): Record<
async function computeAssetTypeBalances(req: ReturnType<typeof parseAssetTypeBalancePayload>): Promise<Record<
string,
{
assetType: string
decimals: number
balance: string
contracts?: string
}
> {
>> {
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,
{
Expand All @@ -110,13 +112,35 @@ function computeAssetTypeBalances(req: ReturnType<typeof parseAssetTypeBalancePa
}
> = {}

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 } : {}),
}
}
}

Expand Down Expand Up @@ -155,7 +179,7 @@ async function handleAuthorizeFetch(event: ServiceWorkerFetchEvent): Promise<voi

const fastPayload = signaturedata ? parseAssetTypeBalancePayload(signaturedata) : null
if (fastPayload) {
const balances = computeAssetTypeBalances(fastPayload)
const balances = await computeAssetTypeBalances(fastPayload)
await respondJson(event, [balances])
return
}
Expand Down
Loading