diff --git a/deno.lock b/deno.lock
index 9a9f17119..67f488349 100644
--- a/deno.lock
+++ b/deno.lock
@@ -10344,7 +10344,8 @@
"npm:vite-plugin-mkcert@^1.17.9",
"npm:vite-tsconfig-paths@^5.1.0",
"npm:vite@^7.3.0",
- "npm:vitest@4"
+ "npm:vitest@4",
+ "npm:zod@^4.1.13"
]
}
},
diff --git a/miniapps/teleport/manifest.json b/miniapps/teleport/manifest.json
index bd13d1a6b..c744a7c72 100644
--- a/miniapps/teleport/manifest.json
+++ b/miniapps/teleport/manifest.json
@@ -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,
diff --git a/miniapps/teleport/package.json b/miniapps/teleport/package.json
index 1eb3f44dc..a1be2af58 100644
--- a/miniapps/teleport/package.json
+++ b/miniapps/teleport/package.json
@@ -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:*",
diff --git a/miniapps/teleport/src/App.tsx b/miniapps/teleport/src/App.tsx
index 902309ad0..f140fee3b 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 } 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';
@@ -35,6 +35,7 @@ import {
type InternalChainName,
type TransferAssetTransaction,
type TronTransaction,
+ type Trc20Transaction,
SWAP_ORDER_STATE_ID,
} from './api';
@@ -53,20 +54,68 @@ function isRecord(value: unknown): value is Record {
}
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 {
@@ -107,6 +156,17 @@ const CHAIN_COLORS: Record = {
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('connect');
@@ -119,7 +179,13 @@ export default function App() {
const [orderId, setOrderId] = useState(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 });
@@ -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' });
@@ -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({
method: 'bio_createTransaction',
params: [
{
from: sourceAccount.address,
to: selectedAsset.recipientAddress,
- amount: amount,
+ amount: normalizeInputAmount(amount),
chain: sourceAccount.chain,
asset: selectedAsset.assetType,
+ ...(remark ? { remark } : {}),
},
],
});
@@ -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);
@@ -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 {
@@ -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,
@@ -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) && }
- {assetsLoading ? t('connect.loadingConfig') : loading ? t('connect.loading') : t('connect.button')}
+ {(loading || assetsLoading || assetsFetching) && }
+ {assetsLoading || assetsFetching
+ ? t('connect.loadingConfig')
+ : loading
+ ? t('connect.loading')
+ : t('connect.button')}
- {assetsError && {t('connect.configError')}
}
+ {assetsError && (
+
+
{t('connect.configError')}
+
+
+ {assetsError instanceof Error ? assetsError.message : String(assetsError)}
+
+
+ )}
)}
@@ -542,6 +656,12 @@ export default function App() {
)}
+
+ {t('amount.precisionHint', {
+ decimals: selectedAsset.decimals,
+ min: formatMinAmount(selectedAsset.decimals),
+ })}
+
@@ -685,6 +805,16 @@ export default function App() {
{`${selectedAsset?.ratio.numerator}:${selectedAsset?.ratio.denominator}`}
+
+ {t('amount.precision')}
+
+ {t('amount.precisionValue', {
+ decimals: selectedAsset?.decimals ?? 0,
+ min: formatMinAmount(selectedAsset?.decimals ?? 0),
+ })}
+
+
+
{t('confirm.fee')}
diff --git a/miniapps/teleport/src/api/client.test.ts b/miniapps/teleport/src/api/client.test.ts
index bc23c5ef1..6de8e5053 100644
--- a/miniapps/teleport/src/api/client.test.ts
+++ b/miniapps/teleport/src/api/client.test.ts
@@ -35,7 +35,10 @@ describe('Teleport API Client', () => {
targetChain: 'BFMCHAIN',
targetAsset: 'BFM',
ratio: { numerator: 1, denominator: 1 },
- transmitDate: { startDate: '2024-01-01', endDate: '2025-01-01' },
+ transmitDate: {
+ startDate: '2020-01-01',
+ endDate: '2030-12-31',
+ },
},
},
},
@@ -48,7 +51,8 @@ describe('Teleport API Client', () => {
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve(mockResponse),
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
})
const result = await getTransmitAssetTypeList()
@@ -65,7 +69,7 @@ describe('Teleport API Client', () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
- json: () => Promise.resolve({ message: 'Internal Server Error' }),
+ text: () => Promise.resolve(JSON.stringify({ message: 'Internal Server Error' })),
})
await expect(getTransmitAssetTypeList()).rejects.toThrow(ApiError)
@@ -74,7 +78,11 @@ describe('Teleport API Client', () => {
it('should throw ApiError when success is false without result', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve({ success: false, message: 'Not allowed', error: { code: 403 } }),
+ status: 200,
+ text: () =>
+ Promise.resolve(
+ JSON.stringify({ success: false, message: 'Not allowed', error: { code: 403 } }),
+ ),
})
const promise = getTransmitAssetTypeList()
@@ -90,7 +98,7 @@ describe('Teleport API Client', () => {
toTrInfo: {
chainName: 'BFMCHAIN' as const,
address: '0xabc',
- assetType: 'BFM',
+ assetType: 'BFM' as const,
},
}
@@ -98,7 +106,8 @@ describe('Teleport API Client', () => {
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve(mockResponse),
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
})
const result = await transmit(mockRequest)
@@ -118,12 +127,32 @@ describe('Teleport API Client', () => {
const mockResponse = {
page: 1,
pageSize: 10,
- dataList: [{ orderId: '1', state: 1, orderState: 4, createdTime: '2024-01-01T00:00:00Z' }],
+ dataList: [
+ {
+ orderId: '1',
+ state: 1,
+ orderState: 4,
+ createdTime: '2024-01-01T00:00:00.000Z',
+ fromTxInfo: {
+ chainName: 'ETH',
+ amount: '0.1',
+ asset: 'ETH',
+ decimals: 18,
+ },
+ toTxInfo: {
+ chainName: 'BFMCHAIN',
+ amount: '0.1',
+ asset: 'BFM',
+ decimals: 8,
+ },
+ },
+ ],
}
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve(mockResponse),
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
})
const result = await getTransmitRecords({ page: 1, pageSize: 10 })
@@ -137,7 +166,8 @@ describe('Teleport API Client', () => {
it('should include filter params', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve({ page: 1, pageSize: 10, dataList: [] }),
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify({ page: 1, pageSize: 10, dataList: [] })),
})
await getTransmitRecords({
@@ -159,14 +189,21 @@ describe('Teleport API Client', () => {
state: 3,
orderState: 4,
swapRatio: 1,
- updatedTime: '2024-01-01T00:00:00Z',
- fromTxInfo: { chainName: 'ETH', address: '0x123' },
- toTxInfo: { chainName: 'BFMCHAIN', address: 'bfmeta123' },
+ updatedTime: '2024-01-01T00:00:00.000Z',
+ fromTxInfo: {
+ chainName: 'ETH',
+ address: '0x123',
+ },
+ toTxInfo: {
+ chainName: 'BFMCHAIN',
+ address: 'bfm123',
+ },
}
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve(mockResponse),
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify(mockResponse)),
})
const result = await getTransmitRecordDetail('order-123')
@@ -182,7 +219,8 @@ describe('Teleport API Client', () => {
it('should retry from tx', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve(true),
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify(true)),
})
const result = await retryFromTxOnChain('order-123')
@@ -194,7 +232,8 @@ describe('Teleport API Client', () => {
it('should retry to tx', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve(true),
+ status: 200,
+ text: () => Promise.resolve(JSON.stringify(true)),
})
const result = await retryToTxOnChain('order-123')
diff --git a/miniapps/teleport/src/api/client.ts b/miniapps/teleport/src/api/client.ts
index 606c36917..4683223d4 100644
--- a/miniapps/teleport/src/api/client.ts
+++ b/miniapps/teleport/src/api/client.ts
@@ -12,6 +12,7 @@ import type {
TransmitRecordDetail,
RetryResponse,
} from './types'
+import { buildPaymentUrl } from './config'
import {
transmitAssetTypeListSchema,
transmitSubmitSchema,
@@ -20,12 +21,19 @@ import {
retrySchema,
} from './schemas'
-const API_BASE_URL = 'https://api.eth-metaverse.com/payment'
+const TRANSMIT_API_REQUEST = {
+ TRANSMIT: '/transmit',
+ RETRY_FROM_TX_ONCHAIN: '/transmit/retryFromTxOnChain',
+ RETRY_TO_TX_ONCHAIN: '/transmit/retryToTxOnChain',
+ RECORDS: '/transmit/records',
+ RECORD_DETAIL: '/transmit/recordDetail',
+ ASSET_TYPE_LIST: '/transmit/assetTypeList',
+} as const
-type WrappedResponse = {
+type ApiEnvelope = {
success: boolean
result?: unknown
- error?: unknown
+ error?: { message?: string }
message?: string
}
@@ -33,7 +41,7 @@ function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null
}
-function isWrappedResponse(value: unknown): value is WrappedResponse {
+function isApiEnvelope(value: unknown): value is ApiEnvelope {
return (
isRecord(value) &&
'success' in value &&
@@ -41,16 +49,6 @@ function isWrappedResponse(value: unknown): value is WrappedResponse {
)
}
-function extractWrappedErrorMessage(data: WrappedResponse): string {
- if (data.message) return data.message
- if (isRecord(data.error) && 'message' in data.error) {
- const message = (data.error as { message?: unknown }).message
- if (Array.isArray(message)) return message.join('; ')
- if (typeof message === 'string') return message
- }
- return 'Request failed'
-}
-
class ApiError extends Error {
constructor(
message: string,
@@ -62,12 +60,37 @@ class ApiError extends Error {
}
}
-async function request(
+async function readResponseBody(response: Response): Promise {
+ const text = await response.text().catch(() => '')
+ if (!text) return null
+ try {
+ return JSON.parse(text)
+ } catch {
+ return text
+ }
+}
+
+function unwrapResponse(data: unknown, status: number): unknown {
+ if (isApiEnvelope(data)) {
+ if (data.success) {
+ if ('result' in data) return data.result
+ return data
+ }
+ const message =
+ (data.error && typeof data.error.message === 'string' && data.error.message) ||
+ (typeof data.message === 'string' && data.message) ||
+ 'Request failed'
+ throw new ApiError(message, status, data)
+ }
+ return data
+}
+
+async function request(
endpoint: string,
options: RequestInit = {},
-): Promise {
- const url = `${API_BASE_URL}${endpoint}`
-
+): Promise {
+ const url = buildPaymentUrl(endpoint)
+
const response = await fetch(url, {
...options,
headers: {
@@ -76,22 +99,17 @@ async function request(
},
})
- if (!response.ok) {
- const data = await response.json().catch(() => null)
- throw new ApiError(
- data?.message || `HTTP ${response.status}`,
- response.status,
- data,
- )
- }
+ const data = await readResponseBody(response)
- const data: unknown = await response.json()
- if (isWrappedResponse(data)) {
- if (data.success) return data.result
- throw new ApiError(extractWrappedErrorMessage(data), response.status, data.error ?? data)
+ if (!response.ok) {
+ const message =
+ typeof data === 'object' && data !== null && 'message' in data
+ ? String((data as { message: unknown }).message)
+ : `HTTP ${response.status}`
+ throw new ApiError(message, response.status, data)
}
- return data
+ return unwrapResponse(data, response.status) as T
}
/**
@@ -99,7 +117,7 @@ async function request(
* GET /payment/transmit/assetTypeList
*/
export async function getTransmitAssetTypeList(): Promise {
- const data = await request('/transmit/assetTypeList')
+ const data = await request(TRANSMIT_API_REQUEST.ASSET_TYPE_LIST)
const parsed = transmitAssetTypeListSchema.safeParse(data)
if (!parsed.success) {
throw new ApiError('Invalid transmit asset list response', 0, parsed.error.flatten())
@@ -112,7 +130,7 @@ export async function getTransmitAssetTypeList(): Promise {
- const res = await request('/transmit', {
+ const res = await request(TRANSMIT_API_REQUEST.TRANSMIT, {
method: 'POST',
body: JSON.stringify(data),
})
@@ -137,7 +155,7 @@ export async function getTransmitRecords(
if (params.fromAddress) searchParams.set('fromAddress', params.fromAddress)
if (params.fromAsset) searchParams.set('fromAsset', params.fromAsset)
- const data = await request(`/transmit/records?${searchParams}`)
+ const data = await request(`${TRANSMIT_API_REQUEST.RECORDS}?${searchParams}`)
const parsed = transmitRecordsSchema.safeParse(data)
if (!parsed.success) {
throw new ApiError('Invalid transmit records response', 0, parsed.error.flatten())
@@ -152,7 +170,9 @@ export async function getTransmitRecords(
export async function getTransmitRecordDetail(
orderId: string,
): Promise {
- const data = await request(`/transmit/recordDetail?orderId=${encodeURIComponent(orderId)}`)
+ const data = await request(
+ `${TRANSMIT_API_REQUEST.RECORD_DETAIL}?orderId=${encodeURIComponent(orderId)}`,
+ )
const parsed = transmitRecordDetailSchema.safeParse(data)
if (!parsed.success) {
throw new ApiError('Invalid transmit record detail response', 0, parsed.error.flatten())
@@ -165,7 +185,7 @@ export async function getTransmitRecordDetail(
* POST /payment/transmit/retryFromTxOnChain
*/
export async function retryFromTxOnChain(orderId: string): Promise {
- const data = await request('/transmit/retryFromTxOnChain', {
+ const data = await request(TRANSMIT_API_REQUEST.RETRY_FROM_TX_ONCHAIN, {
method: 'POST',
body: JSON.stringify({ orderId }),
})
@@ -181,7 +201,7 @@ export async function retryFromTxOnChain(orderId: string): Promise {
- const data = await request('/transmit/retryToTxOnChain', {
+ const data = await request(TRANSMIT_API_REQUEST.RETRY_TO_TX_ONCHAIN, {
method: 'POST',
body: JSON.stringify({ orderId }),
})
diff --git a/miniapps/teleport/src/api/config.ts b/miniapps/teleport/src/api/config.ts
new file mode 100644
index 000000000..9d07141e3
--- /dev/null
+++ b/miniapps/teleport/src/api/config.ts
@@ -0,0 +1,24 @@
+const GLOBAL_PREFIX = 'payment'
+const DEFAULT_API_ORIGIN = 'https://api.eth-metaverse.com'
+const RAW_API_BASE_URL = import.meta.env.VITE_TELEPORT_API_BASE_URL || DEFAULT_API_ORIGIN
+
+function normalizeBaseUrl(url: string): string {
+ return url.replace(/\/+$/, '')
+}
+
+function withPaymentPrefix(baseUrl: string): string {
+ const normalized = normalizeBaseUrl(baseUrl)
+ const prefix = `/${GLOBAL_PREFIX}`
+ return normalized.endsWith(prefix) ? normalized : `${normalized}${prefix}`
+}
+
+const PAYMENT_BASE_URL = withPaymentPrefix(RAW_API_BASE_URL)
+
+export function getPaymentBaseUrl(): string {
+ return PAYMENT_BASE_URL
+}
+
+export function buildPaymentUrl(path: string): string {
+ if (!path) return PAYMENT_BASE_URL
+ return `${PAYMENT_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`
+}
diff --git a/miniapps/teleport/src/api/hooks.test.tsx b/miniapps/teleport/src/api/hooks.test.tsx
index 13c80c671..847f13368 100644
--- a/miniapps/teleport/src/api/hooks.test.tsx
+++ b/miniapps/teleport/src/api/hooks.test.tsx
@@ -41,7 +41,7 @@ describe('Teleport API Hooks', () => {
assetType: 'ETH',
recipientAddress: '0x123',
targetChain: 'BFMCHAIN' as const,
- targetAsset: 'BFM',
+ targetAsset: 'BFM' as const,
ratio: { numerator: 1, denominator: 1 },
transmitDate: {
startDate: '2020-01-01',
@@ -78,7 +78,7 @@ describe('Teleport API Hooks', () => {
assetType: 'ETH',
recipientAddress: '0x123',
targetChain: 'BFMCHAIN' as const,
- targetAsset: 'BFM',
+ targetAsset: 'BFM' as const,
ratio: { numerator: 1, denominator: 1 },
transmitDate: {
startDate: '2020-01-01',
@@ -110,7 +110,7 @@ describe('Teleport API Hooks', () => {
assetType: 'ETH',
recipientAddress: '0x123',
targetChain: 'BFMCHAIN' as const,
- targetAsset: 'BFM',
+ targetAsset: 'BFM' as const,
ratio: { numerator: 1, denominator: 1 },
transmitDate: {
startDate: '2020-01-01',
@@ -167,7 +167,19 @@ describe('Teleport API Hooks', () => {
orderId: '1',
state: 3,
orderState: 4,
- createdTime: '2024-01-01',
+ createdTime: '2024-01-01T00:00:00.000Z',
+ fromTxInfo: {
+ chainName: 'ETH' as const,
+ amount: '0.1',
+ asset: 'ETH',
+ decimals: 18,
+ },
+ toTxInfo: {
+ chainName: 'BFMCHAIN' as const,
+ amount: '0.1',
+ asset: 'BFM',
+ decimals: 8,
+ },
},
],
}
@@ -191,7 +203,15 @@ describe('Teleport API Hooks', () => {
state: 3,
orderState: 4,
swapRatio: 1,
- updatedTime: '2024-01-01',
+ updatedTime: '2024-01-01T00:00:00.000Z',
+ fromTxInfo: {
+ chainName: 'ETH' as const,
+ address: '0x123',
+ },
+ toTxInfo: {
+ chainName: 'BFMCHAIN' as const,
+ address: 'bfm123',
+ },
}
vi.mocked(client.getTransmitRecordDetail).mockResolvedValue(mockData)
diff --git a/miniapps/teleport/src/api/hooks.ts b/miniapps/teleport/src/api/hooks.ts
index 459a0be21..05536d98c 100644
--- a/miniapps/teleport/src/api/hooks.ts
+++ b/miniapps/teleport/src/api/hooks.ts
@@ -48,14 +48,16 @@ export function useTransmitAssetTypeList() {
const now = new Date()
const startDate = new Date(config.transmitDate.startDate)
const endDate = new Date(config.transmitDate.endDate)
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) continue
if (now < startDate || now > endDate) continue
+ const assetSymbol = config.assetType || assetKey
assets.push({
- id: `${chainKey}-${assetKey}`,
+ id: `${chainKey}-${assetSymbol}`,
chain: chainKey as ChainName,
- assetType: assetKey,
- symbol: assetKey,
- name: assetKey,
+ assetType: assetSymbol,
+ symbol: assetSymbol,
+ name: assetSymbol,
balance: '0', // 余额需要从钱包获取
decimals: 8, // 默认精度,实际需要从链上获取
recipientAddress: config.recipientAddress,
@@ -81,6 +83,8 @@ export function useTransmit() {
return useMutation({
mutationFn: (data: TransmitRequest) => transmit(data),
+ networkMode: 'always',
+ retry: 0,
onSuccess: () => {
// 传送成功后刷新记录列表
queryClient.invalidateQueries({ queryKey: ['transmit', 'records'] })
diff --git a/miniapps/teleport/src/api/schema.ts b/miniapps/teleport/src/api/schema.ts
new file mode 100644
index 000000000..4a4d1c820
--- /dev/null
+++ b/miniapps/teleport/src/api/schema.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod'
+import type { TransmitAssetTypeListResponse } from './types'
+
+const dateStringSchema = z
+ .string()
+ .refine((value) => !Number.isNaN(Date.parse(value)), {
+ message: 'Invalid date string',
+ })
+
+const fractionSchema = z.object({
+ numerator: z.union([z.string(), z.number()]),
+ denominator: z.union([z.string(), z.number()]),
+})
+
+const transmitSupportSchema = z.object({
+ enable: z.boolean(),
+ isAirdrop: z.boolean().optional().default(false),
+ assetType: z.string(),
+ recipientAddress: z.string(),
+ targetChain: z.string(),
+ targetAsset: z.string(),
+ ratio: fractionSchema,
+ transmitDate: z.object({
+ startDate: dateStringSchema,
+ endDate: dateStringSchema,
+ }),
+ snapshotHeight: z.coerce.number().optional(),
+ contractAddress: z.string().optional(),
+})
+
+const transmitSupportItemSchema = z.record(z.string(), transmitSupportSchema)
+
+const transmitAssetTypeListSchema = z.object({
+ transmitSupport: z.record(z.string(), transmitSupportItemSchema.optional()),
+})
+
+export function parseTransmitAssetTypeList(value: unknown): TransmitAssetTypeListResponse {
+ const parsed = transmitAssetTypeListSchema.safeParse(value)
+ if (!parsed.success) {
+ const detail = parsed.error.issues
+ .map((issue) => `${issue.path.join('.') || 'root'}: ${issue.message}`)
+ .join('; ')
+ throw new Error(`Invalid transmit config: ${detail}`)
+ }
+ return parsed.data as TransmitAssetTypeListResponse
+}
diff --git a/miniapps/teleport/src/api/schemas.ts b/miniapps/teleport/src/api/schemas.ts
index 8bd570f26..e496c39c0 100644
--- a/miniapps/teleport/src/api/schemas.ts
+++ b/miniapps/teleport/src/api/schemas.ts
@@ -3,80 +3,101 @@
*/
import { z } from 'zod'
+import type {
+ ChainName,
+ InternalAssetType,
+ InternalChainName,
+} from './types'
+import { SWAP_ORDER_STATE_ID, SWAP_RECORD_STATE } from './types'
const stringNumber = z.union([z.string(), z.number()])
+const chainNameSchema = z.custom((value) => typeof value === 'string')
+const internalChainNameSchema = z.custom(
+ (value) => typeof value === 'string',
+)
+const internalAssetTypeSchema = z.custom(
+ (value) => typeof value === 'string',
+)
+
const fractionSchema = z.object({
numerator: stringNumber,
denominator: stringNumber,
-}).passthrough()
+})
+
+const snapshotHeightSchema = z.preprocess((value) => {
+ if (typeof value === 'string' && value.trim() !== '') {
+ const parsed = Number(value)
+ return Number.isNaN(parsed) ? value : parsed
+ }
+ return value
+}, z.number())
const transmitSupportSchema = z.object({
enable: z.boolean(),
isAirdrop: z.boolean(),
assetType: z.string(),
recipientAddress: z.string(),
- targetChain: z.string(),
- targetAsset: z.string(),
+ targetChain: internalChainNameSchema,
+ targetAsset: internalAssetTypeSchema,
ratio: fractionSchema,
transmitDate: z.object({
startDate: z.string(),
endDate: z.string(),
- }).passthrough(),
- snapshotHeight: stringNumber.optional(),
+ }),
+ snapshotHeight: snapshotHeightSchema.optional(),
contractAddress: z.string().optional(),
-}).passthrough()
+})
export const transmitAssetTypeListSchema = z.object({
transmitSupport: z.record(z.string(), z.record(z.string(), transmitSupportSchema)),
-}).passthrough()
+})
export const transmitSubmitSchema = z.object({
orderId: z.string(),
-}).passthrough()
+})
const recordTxInfoSchema = z.object({
- chainName: z.string(),
+ chainName: chainNameSchema,
amount: z.string(),
asset: z.string(),
decimals: z.number(),
assetLogoUrl: z.string().optional(),
-}).passthrough()
+})
export const transmitRecordsSchema = z.object({
page: z.number(),
pageSize: z.number(),
dataList: z.array(z.object({
orderId: z.string(),
- state: z.number(),
- orderState: z.number(),
+ state: z.nativeEnum(SWAP_RECORD_STATE),
+ orderState: z.nativeEnum(SWAP_ORDER_STATE_ID),
createdTime: stringNumber,
fromTxInfo: recordTxInfoSchema.optional(),
toTxInfo: recordTxInfoSchema.optional(),
- }).passthrough()),
-}).passthrough()
+ })),
+})
export const transmitRecordDetailSchema = z.object({
- state: z.number(),
- orderState: z.number(),
+ state: z.nativeEnum(SWAP_RECORD_STATE),
+ orderState: z.nativeEnum(SWAP_ORDER_STATE_ID),
updatedTime: stringNumber,
swapRatio: z.number(),
orderFailReason: z.string().optional(),
fromTxInfo: z.object({
- chainName: z.string(),
+ chainName: chainNameSchema,
address: z.string(),
txId: z.string().optional(),
txHash: z.string().optional(),
contractAddress: z.string().optional(),
- }).passthrough(),
+ }).optional(),
toTxInfo: z.object({
- chainName: z.string(),
+ chainName: chainNameSchema,
address: z.string(),
txId: z.string().optional(),
txHash: z.string().optional(),
contractAddress: z.string().optional(),
- }).passthrough(),
-}).passthrough()
+ }).optional(),
+})
export const retrySchema = z.boolean()
-
diff --git a/miniapps/teleport/src/api/types.ts b/miniapps/teleport/src/api/types.ts
index c5f8337f8..f934f8e64 100644
--- a/miniapps/teleport/src/api/types.ts
+++ b/miniapps/teleport/src/api/types.ts
@@ -1,76 +1,67 @@
/**
* Teleport API Types
- *
- * 类型定义参考 @bnqkl/metabox-core@0.5.2 和 @bnqkl/wallet-typings@0.23.8
- * 注意:这些包在 package.json 中作为依赖存在,但当前未被直接 import 使用。
- * 如果不需要运行时依赖,可以考虑移至 devDependencies 或移除。
+ *
+ * 类型以 @bnqkl/metabox-core 与 @bnqkl/wallet-typings 为唯一可信来源,
+ * 并对 JSON 序列化后的字段做必要的结构适配。
*/
+import type {} from '@bnqkl/metabox-core'
+import type {
+ ExternalAssetType as WalletExternalAssetType,
+ ExternalChainName as WalletExternalChainName,
+ InternalAssetType as WalletInternalAssetType,
+ InternalChainName as WalletInternalChainName,
+} from '@bnqkl/wallet-typings'
+
// 链名类型
-export type ExternalChainName = 'ETH' | 'BSC' | 'TRON'
-export type InternalChainName = 'BFMCHAIN' | 'ETHMETA' | 'PMCHAIN' | 'CCCHAIN' | 'BTGMETA' | 'BFCHAINV2'
+export type ExternalChainName = WalletExternalChainName | `${WalletExternalChainName}`
+export type InternalChainName = WalletInternalChainName | `${WalletInternalChainName}`
export type ChainName = ExternalChainName | InternalChainName
// 资产类型
-export type InternalAssetType = string
-export type ExternalAssetType = string
+export type InternalAssetType = WalletInternalAssetType | `${WalletInternalAssetType}`
+export type ExternalAssetType = WalletExternalAssetType | `${WalletExternalAssetType}`
// 分数类型
-export interface Fraction {
- numerator: string | number
- denominator: string | number
-}
+export type Fraction = MetaBoxCore.Fraction
-// 传送支持配置
-export interface TransmitSupport {
- enable: boolean
- isAirdrop: boolean
- assetType: string
- recipientAddress: string
+// 传送支持配置(API 序列化后 transmitDate 为字符串)
+export type TransmitSupport = Omit<
+ MetaBoxCore.Config.TransmitSupport,
+ 'transmitDate' | 'targetChain' | 'targetAsset'
+> & {
targetChain: InternalChainName
targetAsset: InternalAssetType
- ratio: Fraction
transmitDate: {
startDate: string
endDate: string
}
- snapshotHeight?: number
- contractAddress?: string
}
export type TransmitSupportItem = Record
// 传送配置响应
-export interface TransmitAssetTypeListResponse {
- transmitSupport: {
- BFCHAIN?: TransmitSupportItem
- CCCHAIN?: TransmitSupportItem
- BFMCHAIN?: TransmitSupportItem
- ETHMETA?: TransmitSupportItem
- BTGMETA?: TransmitSupportItem
- PMCHAIN?: TransmitSupportItem
- ETH?: TransmitSupportItem
- }
+export type TransmitAssetTypeListResponse = Omit<
+ MetaBoxCore.Api.TransmitAssetTypeListResDto,
+ 'transmitSupport'
+> & {
+ transmitSupport: Record
}
// TRON 交易体
-export interface TronTransaction {
- txID: string
- raw_data: unknown
- raw_data_hex: string
- signature?: string[]
-}
+export type TronTransaction = BFChainWallet.TRON.TronTransaction
+export type Trc20Transaction = BFChainWallet.TRON.Trc20Transaction
// 外链发起方交易体
export interface ExternalFromTrJson {
eth?: { signTransData: string }
bsc?: { signTransData: string }
tron?: TronTransaction
- trc20?: TronTransaction
+ trc20?: Trc20Transaction
}
// 内链发起方交易体
-export interface InternalFromTrJson {
+export type InternalFromTrJson = Omit & {
bcf?: {
chainName: InternalChainName
trJson: TransferAssetTransaction
@@ -78,41 +69,25 @@ export interface InternalFromTrJson {
}
// 转账交易体
-export interface TransferAssetTransaction {
- senderId: string
- recipientId: string
- amount: string
- fee: string
- timestamp: number
- signature: string
- asset: {
- transferAsset: {
- amount: string
- assetType: string
- }
- }
-}
+export type TransferAssetTransaction = WalletTypings.InternalChain.TransferAssetTransaction
// 发起方交易体(合并外链和内链)
-export type FromTrJson = ExternalFromTrJson & InternalFromTrJson
+export type FromTrJson = Omit & InternalFromTrJson
// 接收方交易信息
-export interface ToTrInfo {
+export type ToTrInfo = Omit & {
chainName: InternalChainName
- address: string
assetType: InternalAssetType
}
// 传送请求
-export interface TransmitRequest {
+export type TransmitRequest = Omit & {
fromTrJson: FromTrJson
toTrInfo?: ToTrInfo
}
// 传送响应
-export interface TransmitResponse {
- orderId: string
-}
+export type TransmitResponse = MetaBoxCore.Api.TransmitResDto
// 订单状态
export enum SWAP_ORDER_STATE_ID {
@@ -133,66 +108,56 @@ export enum SWAP_RECORD_STATE {
}
// 交易信息
-export interface RecordTxInfo {
- chainName: string
- amount: string
- asset: string
- decimals: number
- assetLogoUrl?: string
+export type RecordTxInfo = Omit & {
+ chainName: ChainName
}
// 交易详情信息
-export interface RecordDetailTxInfo {
- chainName: string
- address: string
- txId?: string
- txHash?: string
- contractAddress?: string
+export type RecordDetailTxInfo = Omit & {
+ chainName: ChainName
}
// 传送记录
-export interface TransmitRecord {
- orderId: string
+export type TransmitRecord = Omit<
+ MetaBoxCore.Swap.SwapRecord,
+ 'createdTime' | 'fromTxInfo' | 'toTxInfo' | 'orderState' | 'state'
+> & {
+ createdTime: string | number
state: SWAP_RECORD_STATE
orderState: SWAP_ORDER_STATE_ID
fromTxInfo?: RecordTxInfo
toTxInfo?: RecordTxInfo
- createdTime: string | number
}
// 传送记录详情
-export interface TransmitRecordDetail {
+export type TransmitRecordDetail = Omit<
+ MetaBoxCore.Api.TransmitRecordDetailResDto,
+ 'updatedTime' | 'fromTxInfo' | 'toTxInfo' | 'orderState' | 'state'
+> & {
+ updatedTime: string | number
state: SWAP_RECORD_STATE
orderState: SWAP_ORDER_STATE_ID
fromTxInfo?: RecordDetailTxInfo
toTxInfo?: RecordDetailTxInfo
orderFailReason?: string
- updatedTime: string | number
swapRatio: number
}
// 分页请求
-export interface PageRequest {
- page: number
- pageSize: number
-}
+export type PageRequest = MetaBoxCore.PageRequest
// 记录列表请求
-export interface TransmitRecordsRequest extends PageRequest {
+export type TransmitRecordsRequest = Omit & {
fromChain?: ChainName
- fromAddress?: string
- fromAsset?: string
}
// 记录列表响应
-export interface TransmitRecordsResponse {
- page: number
- pageSize: number
+export type TransmitRecordsResponse = Omit & {
dataList: TransmitRecord[]
}
// 重试响应
-export type RetryResponse = boolean
+export type RetryResponse = MetaBoxCore.Api.TransmitRetryFromTxOnChainResDto
// UI 用的资产展示类型
export interface DisplayAsset {
diff --git a/miniapps/teleport/src/components/AuroraBackground.tsx b/miniapps/teleport/src/components/AuroraBackground.tsx
index 77807ebbf..f12be2902 100644
--- a/miniapps/teleport/src/components/AuroraBackground.tsx
+++ b/miniapps/teleport/src/components/AuroraBackground.tsx
@@ -1,6 +1,6 @@
-"use client";
-import { cn } from "@/lib/utils";
-import React, { ReactNode } from "react";
+'use client';
+import { cn } from '@/lib/utils';
+import React, { ReactNode } from 'react';
interface AuroraBackgroundProps extends React.HTMLProps {
children: ReactNode;
@@ -17,28 +17,16 @@ export const AuroraBackground = ({
diff --git a/miniapps/teleport/src/i18n/locales/en.json b/miniapps/teleport/src/i18n/locales/en.json
index 9af8eb21d..4cdc07d8b 100644
--- a/miniapps/teleport/src/i18n/locales/en.json
+++ b/miniapps/teleport/src/i18n/locales/en.json
@@ -35,7 +35,10 @@
"placeholder": "0.00",
"max": "MAX",
"next": "Next",
- "expected": "Expected to receive"
+ "expected": "Expected to receive",
+ "precision": "Precision",
+ "precisionValue": "{{decimals}} decimals (min {{min}})",
+ "precisionHint": "Precision {{decimals}} decimals, min {{min}}"
},
"target": {
"title": "Select Target Wallet",
diff --git a/miniapps/teleport/src/i18n/locales/zh-CN.json b/miniapps/teleport/src/i18n/locales/zh-CN.json
index 2e58faf34..424e09979 100644
--- a/miniapps/teleport/src/i18n/locales/zh-CN.json
+++ b/miniapps/teleport/src/i18n/locales/zh-CN.json
@@ -35,7 +35,10 @@
"placeholder": "0.00",
"max": "MAX",
"next": "下一步",
- "expected": "预计获得"
+ "expected": "预计获得",
+ "precision": "精度",
+ "precisionValue": "{{decimals}} 位(最小 {{min}})",
+ "precisionHint": "精度 {{decimals}} 位,最小 {{min}}"
},
"target": {
"title": "选择目标钱包",
diff --git a/miniapps/teleport/src/i18n/locales/zh-TW.json b/miniapps/teleport/src/i18n/locales/zh-TW.json
index d78485f72..9bbd11f0e 100644
--- a/miniapps/teleport/src/i18n/locales/zh-TW.json
+++ b/miniapps/teleport/src/i18n/locales/zh-TW.json
@@ -35,7 +35,10 @@
"placeholder": "0.00",
"max": "MAX",
"next": "下一步",
- "expected": "預計獲得"
+ "expected": "預計獲得",
+ "precision": "精度",
+ "precisionValue": "{{decimals}} 位(最小 {{min}})",
+ "precisionHint": "精度 {{decimals}} 位,最小 {{min}}"
},
"target": {
"title": "選擇目標錢包",
diff --git a/miniapps/teleport/src/i18n/locales/zh.json b/miniapps/teleport/src/i18n/locales/zh.json
index 2e58faf34..424e09979 100644
--- a/miniapps/teleport/src/i18n/locales/zh.json
+++ b/miniapps/teleport/src/i18n/locales/zh.json
@@ -35,7 +35,10 @@
"placeholder": "0.00",
"max": "MAX",
"next": "下一步",
- "expected": "预计获得"
+ "expected": "预计获得",
+ "precision": "精度",
+ "precisionValue": "{{decimals}} 位(最小 {{min}})",
+ "precisionHint": "精度 {{decimals}} 位,最小 {{min}}"
},
"target": {
"title": "选择目标钱包",
diff --git a/miniapps/teleport/src/vite-env.d.ts b/miniapps/teleport/src/vite-env.d.ts
new file mode 100644
index 000000000..eb0eeddc9
--- /dev/null
+++ b/miniapps/teleport/src/vite-env.d.ts
@@ -0,0 +1,11 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_TELEPORT_API_BASE_URL?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
+
+export {}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c8237a789..3e16e7339 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -610,6 +610,9 @@ importers:
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
+ zod:
+ specifier: ^4.1.13
+ version: 4.2.1
devDependencies:
'@biochain/e2e-tools':
specifier: workspace:*
diff --git a/src/services/chain-adapter/bioforest/transaction-mixin.ts b/src/services/chain-adapter/bioforest/transaction-mixin.ts
index 7f40b3ab5..090100b4a 100644
--- a/src/services/chain-adapter/bioforest/transaction-mixin.ts
+++ b/src/services/chain-adapter/bioforest/transaction-mixin.ts
@@ -85,6 +85,7 @@ export function BioforestTransactionMixin
}
// 获取手续费
@@ -236,6 +238,8 @@ export function BioforestTransactionMixin
// BioChain 扩展
bioAssetType?: string
}
diff --git a/src/services/ecosystem/handlers/transaction.ts b/src/services/ecosystem/handlers/transaction.ts
index c09833545..0c420a7f7 100644
--- a/src/services/ecosystem/handlers/transaction.ts
+++ b/src/services/ecosystem/handlers/transaction.ts
@@ -93,6 +93,8 @@ export const handleCreateTransaction: MethodHandler = async (params, _context) =
to: opts.to,
amount,
tokenAddress,
+ ...(opts.asset ? { bioAssetType: opts.asset } : {}),
+ ...(opts.remark ? { remark: opts.remark } : {}),
})
if (tokenAddress && chainConfig.chainKind === 'tron') {
diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts
index f4d5a9f71..d51a4db92 100644
--- a/src/services/ecosystem/types.ts
+++ b/src/services/ecosystem/types.ts
@@ -50,6 +50,7 @@ export interface EcosystemTransferParams {
tokenAddress?: string;
/** 资产精度(用于 EVM/TRON Token 转账) */
assetDecimals?: number;
+ remark?: Record;
}
/**