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
3 changes: 2 additions & 1 deletion deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion miniapps/teleport/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
"website": "https://teleport.dweb.xin",
"category": "tools",
"tags": ["转账", "跨链", "资产管理"],
"permissions": ["bio_requestAccounts", "bio_selectAccount", "bio_pickWallet", "bio_signMessage"],
"permissions": [
"bio_selectAccount",
"bio_pickWallet",
"bio_getBalance",
"bio_createTransaction",
"bio_signTransaction"
],
"chains": ["bfmeta", "bfchainv2", "biwmeta", "ccchain", "pmchain"],
"officialScore": 90,
"communityScore": 80,
Expand Down
3 changes: 2 additions & 1 deletion miniapps/teleport/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.5.2",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@biochain/e2e-tools": "workspace:*",
Expand Down
188 changes: 159 additions & 29 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 } from '@biochain/bio-sdk';
import type { BioAccount, BioSignedTransaction, 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 @@ -35,6 +35,7 @@ import {
type InternalChainName,
type TransferAssetTransaction,
type TronTransaction,
type Trc20Transaction,
SWAP_ORDER_STATE_ID,
} from './api';

Expand All @@ -53,20 +54,68 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}

function isTronPayload(value: unknown): value is TronTransaction {
return isRecord(value)
if (!isRecord(value)) return false
if (typeof value.txID !== 'string') return false
if (typeof value.raw_data_hex !== 'string') return false
if (!('raw_data' in value)) return false
const rawData = value.raw_data
if (!isRecord(rawData)) return false
if (!Array.isArray(rawData.contract)) return false
const contract = rawData.contract[0]
if (!isRecord(contract)) return false
const parameter = contract.parameter
if (!isRecord(parameter)) return false
const paramValue = parameter.value
if (!isRecord(paramValue)) return false
return (
typeof paramValue.owner_address === 'string' &&
typeof paramValue.to_address === 'string' &&
typeof paramValue.amount === 'number'
)
}

function isTrc20Payload(value: unknown): value is Trc20Transaction {
if (!isRecord(value)) return false
if (typeof value.txID !== 'string') return false
if (typeof value.raw_data_hex !== 'string') return false
if (!('raw_data' in value)) return false
const rawData = value.raw_data
if (!isRecord(rawData)) return false
if (!Array.isArray(rawData.contract)) return false
const contract = rawData.contract[0]
if (!isRecord(contract)) return false
const parameter = contract.parameter
if (!isRecord(parameter)) return false
const paramValue = parameter.value
if (!isRecord(paramValue)) return false
return (
typeof paramValue.owner_address === 'string' &&
typeof paramValue.contract_address === 'string'
)
}

function getTronSignedPayload(data: unknown, label: string): TronTransaction {
function getTronSignedPayload(data: unknown, label: 'TRON'): TronTransaction
function getTronSignedPayload(data: unknown, label: 'TRC20'): Trc20Transaction
function getTronSignedPayload(
data: unknown,
label: 'TRON' | 'TRC20',
): TronTransaction | Trc20Transaction {
if (isRecord(data) && 'signedTx' in data) {
const maybeSigned = (data as { signedTx?: unknown }).signedTx
if (isTronPayload(maybeSigned)) {
if (label === 'TRC20' && isTrc20Payload(maybeSigned)) {
return maybeSigned
}
if (label === 'TRON' && isTronPayload(maybeSigned)) {
return maybeSigned
}
}
if (!isTronPayload(data)) {
throw new Error(`Invalid ${label} transaction payload`)
if (label === 'TRC20' && isTrc20Payload(data)) {
return data
}
return data
if (label === 'TRON' && isTronPayload(data)) {
return data
}
throw new Error(`Invalid ${label} transaction payload`)
}

function isTransferAssetTransaction(value: unknown): value is TransferAssetTransaction {
Expand Down Expand Up @@ -107,6 +156,17 @@ const CHAIN_COLORS: Record<string, string> = {
PMCHAIN: 'bg-cyan-600',
};

const normalizeInternalChainName = (value: string): InternalChainName =>
value.toUpperCase() as InternalChainName;

const normalizeInputAmount = (value: string) =>
value.includes('.') ? value : `${value}.0`;

const formatMinAmount = (decimals: number) => {
if (decimals <= 0) return '1';
return `0.${'0'.repeat(decimals - 1)}1`;
};

export default function App() {
const { t } = useTranslation();
const [step, setStep] = useState<Step>('connect');
Expand All @@ -119,7 +179,13 @@ export default function App() {
const [orderId, setOrderId] = useState<string | null>(null);

// API Hooks
const { data: assets, isLoading: assetsLoading, error: assetsError } = useTransmitAssetTypeList();
const {
data: assets,
isLoading: assetsLoading,
isFetching: assetsFetching,
error: assetsError,
refetch: refetchAssets,
} = useTransmitAssetTypeList();
const transmitMutation = useTransmit();
const { data: recordDetail } = useTransmitRecordDetail(orderId || '', { enabled: !!orderId });

Expand All @@ -138,6 +204,15 @@ export default function App() {
}
}, [recordDetail]);

useEffect(() => {
if (step !== 'processing') return;
if (!transmitMutation.isError) return;
const err = transmitMutation.error;
setError(err instanceof Error ? err.message : String(err));
setStep('error');
setLoading(false);
}, [step, transmitMutation.isError, transmitMutation.error]);

// 关闭启动屏
useEffect(() => {
window.bio?.request({ method: 'bio_closeSplashScreen' });
Expand Down Expand Up @@ -230,16 +305,39 @@ export default function App() {
setError(null);

try {
// 1. 创建未签名交易(转账到 recipientAddress)
const unsignedTx = await window.bio.request({
// 1. 构造 toTrInfo
const toTrInfo: ToTrInfo = {
chainName: normalizeInternalChainName(selectedAsset.targetChain),
address: targetAccount.address,
assetType: selectedAsset.targetAsset,
};

const chainLower = sourceAccount.chain.toLowerCase();
const isInternalChain =
chainLower !== 'eth' &&
chainLower !== 'bsc' &&
chainLower !== 'tron' &&
chainLower !== 'trc20';

const remark = isInternalChain
? {
chainName: toTrInfo.chainName,
address: toTrInfo.address,
assetType: toTrInfo.assetType,
}
: undefined;

// 2. 创建未签名交易(转账到 recipientAddress)
const unsignedTx = await window.bio.request<BioUnsignedTransaction>({
method: 'bio_createTransaction',
params: [
{
from: sourceAccount.address,
to: selectedAsset.recipientAddress,
amount: amount,
amount: normalizeInputAmount(amount),
chain: sourceAccount.chain,
asset: selectedAsset.assetType,
...(remark ? { remark } : {}),
},
],
});
Expand All @@ -257,11 +355,9 @@ export default function App() {
],
});

// 3. 构造 fromTrJson(根据链类型)
// 注意:signTransData 需要使用 signedTx.data(RLP/Protobuf encoded raw signed tx),
// 而非 signedTx.signature(仅包含签名数据,不是可广播的 rawTx)
// 4. 构造 fromTrJson(根据链类型)
// 注意:EVM 需要 raw signed tx 的 hex;TRON/内链需要结构化交易体
const fromTrJson: FromTrJson = {};
const chainLower = sourceAccount.chain.toLowerCase();
const signTransData = typeof signedTx.data === 'string'
? signedTx.data
: superjson.stringify(signedTx.data);
Expand All @@ -273,10 +369,11 @@ export default function App() {
} else if (chainLower === 'bsc') {
fromTrJson.bsc = { signTransData };
} else if (isTronChain) {
const tronPayload = getTronSignedPayload(signedTx.data, isTrc20 ? 'TRC20' : 'TRON');
if (isTrc20) {
const tronPayload = getTronSignedPayload(signedTx.data, 'TRC20');
fromTrJson.trc20 = tronPayload;
} else {
const tronPayload = getTronSignedPayload(signedTx.data, 'TRON');
fromTrJson.tron = tronPayload;
}
} else {
Expand All @@ -286,19 +383,15 @@ export default function App() {
throw new Error('Invalid internal signed transaction payload');
}
fromTrJson.bcf = {
chainName: sourceAccount.chain as InternalChainName,
chainName: normalizeInternalChainName(sourceAccount.chain),
trJson: internalTrJson,
};
}

// 4. 构造 toTrInfo
const toTrInfo: ToTrInfo = {
chainName: selectedAsset.targetChain,
address: targetAccount.address,
assetType: selectedAsset.targetAsset,
};

// 5. 发起传送请求
// 6. 发起传送请求
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
throw new Error('网络不可用')
}
setStep('processing');
const result = await transmitMutation.mutateAsync({
fromTrJson,
Expand Down Expand Up @@ -409,13 +502,34 @@ export default function App() {
size="lg"
className="h-12 w-full max-w-xs"
onClick={handleConnect}
disabled={loading || assetsLoading}
disabled={loading || assetsLoading || assetsFetching || !!assetsError}
>
{(loading || assetsLoading) && <Loader2 className="mr-2 size-4 animate-spin" />}
{assetsLoading ? t('connect.loadingConfig') : loading ? t('connect.loading') : t('connect.button')}
{(loading || assetsLoading || assetsFetching) && <Loader2 className="mr-2 size-4 animate-spin" />}
{assetsLoading || assetsFetching
? t('connect.loadingConfig')
: loading
? t('connect.loading')
: t('connect.button')}
</Button>

{assetsError && <p className="text-destructive text-sm">{t('connect.configError')}</p>}
{assetsError && (
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-destructive text-sm">{t('connect.configError')}</p>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => refetchAssets()}
disabled={assetsFetching}
>
<RefreshCw className={cn('size-4', assetsFetching && 'animate-spin')} />
{t('error.retry')}
</Button>
<p className="text-muted-foreground text-xs">
{assetsError instanceof Error ? assetsError.message : String(assetsError)}
</p>
</div>
)}
</motion.div>
)}

Expand Down Expand Up @@ -542,6 +656,12 @@ export default function App() {
</span>
</p>
)}
<p className="text-muted-foreground text-xs">
{t('amount.precisionHint', {
decimals: selectedAsset.decimals,
min: formatMinAmount(selectedAsset.decimals),
})}
</p>
</CardContent>
</Card>

Expand Down Expand Up @@ -685,6 +805,16 @@ export default function App() {
<span>{`${selectedAsset?.ratio.numerator}:${selectedAsset?.ratio.denominator}`}</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">{t('amount.precision')}</span>
<span>
{t('amount.precisionValue', {
decimals: selectedAsset?.decimals ?? 0,
min: formatMinAmount(selectedAsset?.decimals ?? 0),
})}
</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">{t('confirm.fee')}</span>
<Badge className="bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20">
Expand Down
Loading