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; } /**