From 5f6cf90676ac420243e2fc7578b77f479980a27f Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:03:39 +0800 Subject: [PATCH 1/5] feat(miniapps/forge): integrate COT Recharge API - Add API types for COT Recharge (types.ts) - Add API client and service layer (client.ts, recharge.ts) - Add helper functions for message encoding (helpers.ts) - Create React hooks: - useRechargeConfig: fetch and parse forge options - useForge: handle forge flow (sign external tx, sign internal msg, submit) - useContractPool: fetch pool statistics - useRechargeRecords: fetch transaction records - Update App.tsx to use real API: - Load config from backend on mount - Support dynamic token selection from config - Show processing steps during forge - Display order ID on success - Update tests to mock API calls --- miniapps/forge/src/App.test.tsx | 64 ++- miniapps/forge/src/App.tsx | 446 ++++++++++-------- miniapps/forge/src/api/client.ts | 71 +++ miniapps/forge/src/api/config.ts | 25 + miniapps/forge/src/api/helpers.ts | 45 ++ miniapps/forge/src/api/index.ts | 8 + miniapps/forge/src/api/recharge.ts | 61 +++ miniapps/forge/src/api/types.ts | 220 +++++++++ miniapps/forge/src/hooks/index.ts | 14 + miniapps/forge/src/hooks/useContractPool.ts | 46 ++ miniapps/forge/src/hooks/useForge.ts | 174 +++++++ miniapps/forge/src/hooks/useRechargeConfig.ts | 90 ++++ .../forge/src/hooks/useRechargeRecords.ts | 122 +++++ 13 files changed, 1180 insertions(+), 206 deletions(-) create mode 100644 miniapps/forge/src/api/client.ts create mode 100644 miniapps/forge/src/api/config.ts create mode 100644 miniapps/forge/src/api/helpers.ts create mode 100644 miniapps/forge/src/api/index.ts create mode 100644 miniapps/forge/src/api/recharge.ts create mode 100644 miniapps/forge/src/api/types.ts create mode 100644 miniapps/forge/src/hooks/index.ts create mode 100644 miniapps/forge/src/hooks/useContractPool.ts create mode 100644 miniapps/forge/src/hooks/useForge.ts create mode 100644 miniapps/forge/src/hooks/useRechargeConfig.ts create mode 100644 miniapps/forge/src/hooks/useRechargeRecords.ts diff --git a/miniapps/forge/src/App.test.tsx b/miniapps/forge/src/App.test.tsx index 9b383c40..714cd9b9 100644 --- a/miniapps/forge/src/App.test.tsx +++ b/miniapps/forge/src/App.test.tsx @@ -2,6 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import App from './App' +// Mock the API module +vi.mock('@/api', () => ({ + rechargeApi: { + getSupport: vi.fn().mockResolvedValue({ + recharge: { + bfmeta: { + BFM: { + enable: true, + chainName: 'bfmeta', + assetType: 'BFM', + applyAddress: 'b0000000000000000000000000000000000000000', + supportChain: { + ETH: { + enable: true, + assetType: 'ETH', + depositAddress: '0x1234567890123456789012345678901234567890', + }, + }, + }, + }, + }, + }), + submitRecharge: vi.fn(), + }, +})) + // Mock bio SDK const mockBio = { request: vi.fn(), @@ -16,10 +42,12 @@ describe('Forge App', () => { ;(window as unknown as { bio: typeof mockBio }).bio = mockBio }) - it('should render initial connect step', () => { + it('should render initial connect step after config loads', async () => { render() - expect(screen.getByText('多链熔炉')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('多链熔炉')).toBeInTheDocument() + }) expect(screen.getByText(/将其他链资产锻造为/)).toBeInTheDocument() expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument() }) @@ -31,9 +59,15 @@ describe('Forge App', () => { render() + await waitFor(() => { + expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: '连接钱包' })) - expect(screen.getByRole('button', { name: '连接中...' })).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByRole('button', { name: '连接中...' })).toBeInTheDocument() + }) }) it('should proceed to swap step after selecting wallet', async () => { @@ -41,10 +75,14 @@ describe('Forge App', () => { render() + await waitFor(() => { + expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: '连接钱包' })) await waitFor(() => { - expect(screen.getByText('支付')).toBeInTheDocument() + expect(screen.getByText(/支付/)).toBeInTheDocument() }) }) @@ -53,6 +91,10 @@ describe('Forge App', () => { render() + await waitFor(() => { + expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: '连接钱包' })) await waitFor(() => { @@ -65,6 +107,10 @@ describe('Forge App', () => { render() + await waitFor(() => { + expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: '连接钱包' })) await waitFor(() => { @@ -72,17 +118,21 @@ describe('Forge App', () => { }) }) - it('should call bio_selectAccount on connect', async () => { - mockBio.request.mockResolvedValue({ address: '0x123', chain: 'ethereum' }) + it('should call bio_selectAccount on connect with chain param', async () => { + mockBio.request.mockResolvedValue({ address: '0x123', chain: 'eth' }) render() + await waitFor(() => { + expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: '连接钱包' })) await waitFor(() => { expect(mockBio.request).toHaveBeenCalledWith({ method: 'bio_selectAccount', - params: [{}], + params: [{ chain: 'eth' }], }) }) }) diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index 8ee80a35..639b0d0a 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -1,7 +1,7 @@ -import { useState, useCallback, useEffect } from 'react' -import type { BioAccount, BioUnsignedTransaction, BioSignedTransaction } from '@biochain/bio-sdk' +import { useState, useCallback, useEffect, useMemo } from 'react' +import type { BioAccount } from '@biochain/bio-sdk' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Badge } from '@/components/ui/badge' @@ -10,71 +10,69 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { BackgroundBeams } from './components/BackgroundBeams' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' -import { Coins, Leaf, DollarSign, Bitcoin, X, ChevronDown, ArrowUpDown, ChevronLeft, Zap, ArrowDown, Check, Loader2 } from 'lucide-react' +import { Coins, Leaf, DollarSign, X, ChevronDown, ChevronLeft, Zap, ArrowDown, Check, Loader2, AlertCircle } from 'lucide-react' -type Step = 'connect' | 'swap' | 'confirm' | 'processing' | 'success' - -interface Token { - symbol: string - name: string - chain: string - balance?: string -} - -const TOKENS: Token[] = [ - { symbol: 'ETH', name: 'Ethereum', chain: 'ethereum' }, - { symbol: 'BFM', name: 'BioForest', chain: 'bfmeta' }, - { symbol: 'USDT', name: 'Tether', chain: 'ethereum' }, - { symbol: 'BTC', name: 'Bitcoin', chain: 'bitcoin' }, -] - -const FORGE_RECEIVER: Record = { - ethereum: '0x000000000000000000000000000000000000dEaD', - bfmeta: 'b0000000000000000000000000000000000000000', - bitcoin: 'bc1q000000000000000000000000000000000000000', -} +import { useRechargeConfig, useForge, type ForgeOption } from '@/hooks' +import type { ExternalChainName } from '@/api/types' -const EXCHANGE_RATES: Record = { - 'ETH-BFM': 2500, - 'BFM-ETH': 0.0004, - 'USDT-BFM': 1, - 'BFM-USDT': 1, - 'BTC-BFM': 45000, - 'BFM-BTC': 0.000022, -} +type Step = 'connect' | 'swap' | 'confirm' | 'processing' | 'success' const TOKEN_COLORS: Record = { ETH: 'bg-indigo-600', + BSC: 'bg-yellow-600', + TRON: 'bg-red-600', BFM: 'bg-emerald-600', USDT: 'bg-teal-600', - BTC: 'bg-orange-600', + BFC: 'bg-blue-600', +} + +const CHAIN_NAMES: Record = { + ETH: 'Ethereum', + BSC: 'BNB Chain', + TRON: 'Tron', + bfmeta: 'BFMeta', + bfchain: 'BFChain', } export default function App() { const [step, setStep] = useState('connect') - const [account, setAccount] = useState(null) - const [fromToken, setFromToken] = useState(TOKENS[0]) - const [toToken, setToToken] = useState(TOKENS[1]) - const [fromAmount, setFromAmount] = useState('') - const [toAmount, setToAmount] = useState('') + const [externalAccount, setExternalAccount] = useState(null) + const [internalAccount, setInternalAccount] = useState(null) + const [selectedOption, setSelectedOption] = useState(null) + const [amount, setAmount] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [pickerOpen, setPickerOpen] = useState<'from' | 'to' | null>(null) + const [pickerOpen, setPickerOpen] = useState(false) - // 关闭启动屏 + // Fetch recharge config from backend + const { forgeOptions, isLoading: configLoading, error: configError } = useRechargeConfig() + + // Forge hook + const forgeHook = useForge() + + // Close splash screen useEffect(() => { window.bio?.request({ method: 'bio_closeSplashScreen' }) }, []) + // Auto-select first option when config loads + useEffect(() => { + if (forgeOptions.length > 0 && !selectedOption) { + setSelectedOption(forgeOptions[0]) + } + }, [forgeOptions, selectedOption]) + + // Watch forge status useEffect(() => { - if (fromAmount && parseFloat(fromAmount) > 0) { - const rate = EXCHANGE_RATES[`${fromToken.symbol}-${toToken.symbol}`] || 1 - const result = parseFloat(fromAmount) * rate - setToAmount(result.toFixed(fromToken.symbol === 'BFM' ? 8 : 2)) - } else { - setToAmount('') + if (forgeHook.step === 'success') { + setStep('success') + } else if (forgeHook.step === 'error') { + setError(forgeHook.error) + setStep('confirm') + } else if (forgeHook.step !== 'idle') { + setStep('processing') } - }, [fromAmount, fromToken, toToken]) + }, [forgeHook.step, forgeHook.error]) const handleConnect = useCallback(async () => { if (!window.bio) { @@ -84,29 +82,30 @@ export default function App() { setLoading(true) setError(null) try { - const acc = await window.bio.request({ + // Select external chain account (for payment) + const extAcc = await window.bio.request({ method: 'bio_selectAccount', - params: [{}], + params: [{ chain: selectedOption?.externalChain?.toLowerCase() }], }) - setAccount(acc) - setFromToken({ ...fromToken, balance: '2.5' }) + setExternalAccount(extAcc) + + // Select internal chain account (for receiving) + const intAcc = await window.bio.request({ + method: 'bio_selectAccount', + params: [{ chain: selectedOption?.internalChain }], + }) + setInternalAccount(intAcc) + setStep('swap') } catch (err) { setError(err instanceof Error ? err.message : '连接失败') } finally { setLoading(false) } - }, [fromToken]) - - const handleSwapTokens = () => { - const temp = fromToken - setFromToken({ ...toToken, balance: toToken.balance }) - setToToken({ ...temp, balance: temp.balance }) - setFromAmount(toAmount) - } + }, [selectedOption]) const handlePreview = () => { - if (!fromAmount || parseFloat(fromAmount) <= 0) { + if (!amount || parseFloat(amount) <= 0) { setError('请输入有效金额') return } @@ -115,48 +114,46 @@ export default function App() { } const handleConfirm = useCallback(async () => { - if (!window.bio || !account) return - setLoading(true) + if (!externalAccount || !internalAccount || !selectedOption) return + setError(null) setStep('processing') - try { - const chainId = fromToken.chain - const to = FORGE_RECEIVER[chainId] ?? FORGE_RECEIVER.ethereum - const unsignedTx = await window.bio.request({ - method: 'bio_createTransaction', - params: [{ from: account.address, to, amount: fromAmount, chain: chainId, asset: fromToken.symbol }], - }) - const signedTx = await window.bio.request({ - method: 'bio_signTransaction', - params: [{ from: account.address, chain: chainId, unsignedTx }], - }) - void signedTx - await new Promise(resolve => setTimeout(resolve, 2000)) - setStep('success') - } catch (err) { - setError(err instanceof Error ? err.message : '交易失败') - setStep('confirm') - } finally { - setLoading(false) - } - }, [account, fromToken, fromAmount]) + + await forgeHook.forge({ + externalChain: selectedOption.externalChain, + externalAsset: selectedOption.externalAsset, + depositAddress: selectedOption.externalInfo.depositAddress, + amount, + externalAccount, + internalChain: selectedOption.internalChain, + internalAsset: selectedOption.internalAsset, + internalAccount, + }) + }, [externalAccount, internalAccount, selectedOption, amount, forgeHook]) const handleReset = useCallback(() => { setStep('swap') - setFromAmount('') - setToAmount('') + setAmount('') setError(null) - }, []) + forgeHook.reset() + }, [forgeHook]) - const handleSelectToken = (token: Token) => { - if (pickerOpen === 'from') { - setFromToken({ ...token, balance: token.symbol === 'ETH' ? '2.5' : '1000' }) - } else { - setToToken(token) - } - setPickerOpen(null) + const handleSelectOption = (option: ForgeOption) => { + setSelectedOption(option) + setPickerOpen(false) } + // Group options by external chain for picker + const groupedOptions = useMemo(() => { + const groups: Record = {} + for (const opt of forgeOptions) { + const key = opt.externalChain + if (!groups[key]) groups[key] = [] + groups[key].push(opt) + } + return groups + }, [forgeOptions]) + return (
@@ -179,6 +176,23 @@ export default function App() { {/* Content */}
+ {/* Loading config */} + {configLoading && ( +
+ +
+ )} + + {/* Config error */} + {configError && ( + + + + {configError} + + + )} + {error && ( {/* Connect */} - {step === 'connect' && ( + {step === 'connect' && !configLoading && (

多链熔炉

-

将其他链资产锻造为 BFM 代币

+

将其他链资产锻造为 Bio 生态代币

+ + {/* Available chains preview */} + {forgeOptions.length > 0 && ( +
+ {Object.keys(groupedOptions).map((chain) => ( + + {CHAIN_NAMES[chain] || chain} + + ))} +
+ )} setFromAmount(e.target.value)} + value={amount} + onChange={(e) => setAmount(e.target.value)} placeholder="0.00" className="text-right text-2xl font-bold h-10 border-0 focus-visible:ring-0" /> - {fromToken.balance && ( -
- {['25%', '50%', '75%', 'MAX'].map((pct, i) => ( - - ))} -
- )} - {/* Swap Button */} + {/* Arrow */}
- + + + + +
- {/* To Card */} + {/* To Card (Internal Chain) */}
- 获得 - ≈ ${toAmount ? (parseFloat(toAmount) * (toToken.symbol === 'BFM' ? 1 : 2500)).toFixed(2) : '--'} + 获得 ({CHAIN_NAMES[selectedOption.internalChain] || selectedOption.internalChain}) + + {internalAccount?.address?.slice(0, 8)}... +
- +
+ + {selectedOption.internalAsset} +
- {toAmount || '0.00'} + {amount || '0.00'}
- {/* Rate Info */} - {fromAmount && parseFloat(fromAmount) > 0 && ( + {/* Rate Info - 1:1 for forge */} + {amount && parseFloat(amount) > 0 && ( - - 汇率 - 1 {fromToken.symbol} ≈ {EXCHANGE_RATES[`${fromToken.symbol}-${toToken.symbol}`] || 1} {toToken.symbol} + +
+ 兑换比例 + 1:1 +
+
+ 充值地址 + + {selectedOption.externalInfo.depositAddress.slice(0, 10)}... + +
)} @@ -337,7 +348,7 @@ export default function App() { @@ -346,7 +357,7 @@ export default function App() { )} {/* Confirm */} - {step === 'confirm' && ( + {step === 'confirm' && selectedOption && (
- 支付 + + 支付 ({CHAIN_NAMES[selectedOption.externalChain] || selectedOption.externalChain}) +
- - {fromAmount} {fromToken.symbol} + + {amount} {selectedOption.externalAsset}
@@ -371,10 +384,12 @@ export default function App() {
- 获得 + + 获得 ({CHAIN_NAMES[selectedOption.internalChain] || selectedOption.internalChain}) +
- - {toAmount} {toToken.symbol} + + {amount} {selectedOption.internalAsset}
@@ -383,22 +398,24 @@ export default function App() {
- 汇率 - 1 {fromToken.symbol} = {EXCHANGE_RATES[`${fromToken.symbol}-${toToken.symbol}`]} {toToken.symbol} + 兑换比例 + 1:1
网络
- {fromToken.chain} + {selectedOption.externalChain} - {toToken.chain} + {selectedOption.internalChain}
- 预计时间 - ~30s + 充值地址 + + {selectedOption.externalInfo.depositAddress.slice(0, 10)}... +
@@ -428,14 +445,19 @@ export default function App() {
-

锻造中...

-

请稍候,正在处理交易

+

+ {forgeHook.step === 'signing_external' && '签名外链交易...'} + {forgeHook.step === 'signing_internal' && '签名内链消息...'} + {forgeHook.step === 'submitting' && '提交锻造请求...'} + {forgeHook.step === 'idle' && '锻造中...'} +

+

请在钱包中确认操作

)} {/* Success */} - {step === 'success' && ( + {step === 'success' && selectedOption && (

锻造完成

-

{toAmount} {toToken.symbol}

+

+ {amount} {selectedOption.internalAsset} +

+ {forgeHook.orderId && ( +

+ 订单: {forgeHook.orderId.slice(0, 16)}... +

+ )}
-
- {TOKENS.filter(t => t.symbol !== (pickerOpen === 'from' ? toToken : fromToken).symbol).map((token) => ( - handleSelectToken(token)} - > - - -
- {token.symbol} - {token.name} -
- {(pickerOpen === 'from' ? fromToken : toToken).symbol === token.symbol && ( - 已选 - )} -
-
+
+ {Object.entries(groupedOptions).map(([chain, options]) => ( +
+

+ {CHAIN_NAMES[chain] || chain} +

+
+ {options.map((option) => ( + handleSelectOption(option)} + > + + +
+ + {option.externalAsset} → {option.internalAsset} + + + {option.externalChain} → {CHAIN_NAMES[option.internalChain] || option.internalChain} + +
+ {selectedOption?.externalAsset === option.externalAsset && + selectedOption?.externalChain === option.externalChain && ( + 已选 + )} +
+
+ ))} +
+
))}
@@ -505,16 +550,19 @@ export default function App() { } function TokenAvatar({ symbol, size = 'sm' }: { symbol: string; size?: 'sm' | 'md' }) { + const iconSize = size === 'md' ? 'size-5' : 'size-4' const icons: Record = { - ETH: , - BFM: , - USDT: , - BTC: , + ETH: , + BSC: , + TRON: , + BFM: , + BFC: , + USDT: , } return ( - {icons[symbol] || } + {icons[symbol] || } ) diff --git a/miniapps/forge/src/api/client.ts b/miniapps/forge/src/api/client.ts new file mode 100644 index 00000000..af407463 --- /dev/null +++ b/miniapps/forge/src/api/client.ts @@ -0,0 +1,71 @@ +/** + * API Client + */ + +import { API_BASE_URL } from './config' + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public data?: unknown, + ) { + super(message) + this.name = 'ApiError' + } +} + +interface RequestOptions extends RequestInit { + params?: Record +} + +async function request(endpoint: string, options: RequestOptions = {}): Promise { + const { params, ...init } = options + + let url = `${API_BASE_URL}${endpoint}` + + if (params) { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + searchParams.append(key, String(value)) + } + } + const queryString = searchParams.toString() + if (queryString) { + url += `?${queryString}` + } + } + + const response = await fetch(url, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + }) + + if (!response.ok) { + const data = await response.json().catch(() => null) + throw new ApiError( + data?.message || `HTTP ${response.status}`, + response.status, + data, + ) + } + + return response.json() +} + +export const apiClient = { + get(endpoint: string, params?: Record): Promise { + return request(endpoint, { method: 'GET', params }) + }, + + post(endpoint: string, body?: unknown): Promise { + return request(endpoint, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }) + }, +} diff --git a/miniapps/forge/src/api/config.ts b/miniapps/forge/src/api/config.ts new file mode 100644 index 00000000..f79c06d2 --- /dev/null +++ b/miniapps/forge/src/api/config.ts @@ -0,0 +1,25 @@ +/** + * API Configuration + * TODO: Base URL needs to be confirmed with backend team + */ + +/** API Base URL - to be configured via environment or runtime */ +export const API_BASE_URL = (import.meta as unknown as { env: Record }).env.VITE_COT_API_BASE_URL || 'https://api.eth-metaverse.com' + +/** API Endpoints */ +export const API_ENDPOINTS = { + /** 获取支持的充值配置 */ + RECHARGE_SUPPORT: '/cot/recharge/support', + /** 发起充值(锻造) */ + RECHARGE_V2: '/cot/recharge/V2', + /** 获取合约池信息 */ + CONTRACT_POOL_INFO: '/cot/recharge/contractPoolInfo', + /** 获取充值记录列表 */ + RECORDS: '/cot/recharge/records', + /** 获取充值记录详情 */ + RECORD_DETAIL: '/cot/recharge/recordDetail', + /** 外链上链重试 */ + RETRY_EXTERNAL: '/cot/recharge/retryExternalOnChain', + /** 内链上链重试 */ + RETRY_INTERNAL: '/cot/recharge/retryInternalOnChain', +} as const diff --git a/miniapps/forge/src/api/helpers.ts b/miniapps/forge/src/api/helpers.ts new file mode 100644 index 00000000..aff2f410 --- /dev/null +++ b/miniapps/forge/src/api/helpers.ts @@ -0,0 +1,45 @@ +/** + * Recharge Helper Functions + * Based on @bnqkl/cotcore helper.js + */ + +import type { RechargeV2ToTrInfoData } from './types' + +/** + * Encode timestamp message for signing + */ +export function encodeTimestampMessage(params: { timestamp: number }): string { + return JSON.stringify({ timestamp: params.timestamp }) +} + +/** + * Encode recharge V2 message for signing + * This is the message that needs to be signed by the internal chain account + */ +export function encodeRechargeV2ToTrInfoData(params: { + chainName: string + address: string + timestamp: number +}): string { + return JSON.stringify({ + chainName: params.chainName, + address: params.address, + timestamp: params.timestamp, + }) +} + +/** + * Create RechargeV2ToTrInfoData from params + */ +export function createRechargeMessage(params: { + chainName: string + address: string + assetType: string +}): RechargeV2ToTrInfoData { + return { + chainName: params.chainName as RechargeV2ToTrInfoData['chainName'], + address: params.address, + assetType: params.assetType, + timestamp: Date.now(), + } +} diff --git a/miniapps/forge/src/api/index.ts b/miniapps/forge/src/api/index.ts new file mode 100644 index 00000000..8a924f58 --- /dev/null +++ b/miniapps/forge/src/api/index.ts @@ -0,0 +1,8 @@ +/** + * API Module Exports + */ + +export * from './types' +export * from './config' +export * from './client' +export { rechargeApi } from './recharge' diff --git a/miniapps/forge/src/api/recharge.ts b/miniapps/forge/src/api/recharge.ts new file mode 100644 index 00000000..539baf42 --- /dev/null +++ b/miniapps/forge/src/api/recharge.ts @@ -0,0 +1,61 @@ +/** + * COT Recharge API + */ + +import { apiClient } from './client' +import { API_ENDPOINTS } from './config' +import type { + RechargeSupportResDto, + RechargeV2ReqDto, + RechargeResDto, + RechargeContractPoolReqDto, + RechargeContractPoolResDto, + RechargeRecordsReqDto, + RechargeRecordsResDto, + RechargeRecordDetailReqDto, + RechargeRecordDetailResDto, + RetryOnChainReqDto, +} from './types' + +export const rechargeApi = { + /** 获取支持的充值配置 */ + getSupport(): Promise { + return apiClient.get(API_ENDPOINTS.RECHARGE_SUPPORT) + }, + + /** 发起充值(锻造) */ + submitRecharge(data: RechargeV2ReqDto): Promise { + return apiClient.post(API_ENDPOINTS.RECHARGE_V2, data) + }, + + /** 获取合约池信息 */ + getContractPoolInfo(params: RechargeContractPoolReqDto): Promise { + return apiClient.get(API_ENDPOINTS.CONTRACT_POOL_INFO, { internalChainName: params.internalChainName }) + }, + + /** 获取充值记录列表 */ + getRecords(params: RechargeRecordsReqDto): Promise { + return apiClient.get(API_ENDPOINTS.RECORDS, { + page: params.page, + pageSize: params.pageSize, + internalChain: params.internalChain, + internalAddress: params.internalAddress, + recordState: params.recordState, + }) + }, + + /** 获取充值记录详情 */ + getRecordDetail(params: RechargeRecordDetailReqDto): Promise { + return apiClient.get(API_ENDPOINTS.RECORD_DETAIL, { orderId: params.orderId }) + }, + + /** 外链上链重试 */ + retryExternal(data: RetryOnChainReqDto): Promise { + return apiClient.post(API_ENDPOINTS.RETRY_EXTERNAL, data) + }, + + /** 内链上链重试 */ + retryInternal(data: RetryOnChainReqDto): Promise { + return apiClient.post(API_ENDPOINTS.RETRY_INTERNAL, data) + }, +} diff --git a/miniapps/forge/src/api/types.ts b/miniapps/forge/src/api/types.ts new file mode 100644 index 00000000..e7238ebc --- /dev/null +++ b/miniapps/forge/src/api/types.ts @@ -0,0 +1,220 @@ +/** + * COT Recharge API Types + * Based on @bnqkl/cotcore@0.7.4 type definitions + */ + +/** 外链名称 */ +export type ExternalChainName = 'ETH' | 'BSC' | 'TRON' + +/** 内链名称 */ +export type InternalChainName = 'bfmeta' | 'bfchain' | 'ccchain' | 'pmchain' + +/** 外链资产信息 */ +export interface ExternalAssetInfoItem { + /** 是否启用 */ + enable: boolean + /** 合约地址(ERC20/BEP20/TRC20) */ + contract?: string + /** 充值地址 */ + depositAddress: string + /** 资产类型名称 */ + assetType: string + /** Logo URL */ + logo?: string + /** 精度 */ + decimals?: number +} + +/** 赎回参数 */ +export interface RedemptionConfig { + enable: boolean + /** 单笔赎回下限(内链最小单位) */ + min: string + /** 单笔赎回上限(内链最小单位) */ + max: string + /** 不同链的手续费 */ + fee: Record + /** 手续费比例 */ + radioFee: string +} + +/** 充值配置项 */ +export interface RechargeItem { + /** 是否启用 */ + enable: boolean + /** 内链名 */ + chainName: InternalChainName + /** 内链代币名(锻造产物) */ + assetType: string + /** 内链币发行地址 */ + applyAddress: string + /** 支持的外链 */ + supportChain: { + ETH?: ExternalAssetInfoItem + BSC?: ExternalAssetInfoItem + TRON?: ExternalAssetInfoItem + } + /** 赎回参数 */ + redemption?: RedemptionConfig + /** Logo */ + logo?: string +} + +/** 充值配置(按内链 -> 资产类型) */ +export type RechargeConfig = Record> + +/** 充值支持配置响应 */ +export interface RechargeSupportResDto { + recharge: RechargeConfig +} + +/** 外链交易体 */ +export interface FromTrJson { + eth?: { signTransData: string } + bsc?: { signTransData: string } + tron?: unknown + trc20?: unknown +} + +/** 内链接收方信息 */ +export interface RechargeV2ToTrInfoData { + chainName: InternalChainName + address: string + assetType: string + timestamp: number +} + +/** 签名信息 */ +export interface SignatureInfo { + timestamp: number + signature: string + publicKey: string +} + +/** 充值请求 */ +export interface RechargeV2ReqDto { + /** 外链已签名交易体 */ + fromTrJson: FromTrJson + /** 内链接收信息 */ + message: RechargeV2ToTrInfoData + /** 验签信息 */ + signatureInfo: SignatureInfo +} + +/** 充值响应 */ +export interface RechargeResDto { + orderId: string +} + +/** 合约池信息请求 */ +export interface RechargeContractPoolReqDto { + internalChainName: InternalChainName +} + +/** 合约池信息项 */ +export interface RechargeContractPoolItem { + chainName: InternalChainName + assetType: string + externalChainInfo: Array<{ + chainName: ExternalChainName + assetType: string + }> + /** 总铸造量 */ + totalMinted: string + /** 当前流通总量 */ + totalCirculation: string + /** 总销毁量 */ + totalBurned: string + /** 总质押量 */ + totalStaked: string +} + +/** 合约池信息响应 */ +export interface RechargeContractPoolResDto { + poolInfo: RechargeContractPoolItem[] +} + +/** 充值订单状态 */ +export enum RECHARGE_ORDER_STATE_ID { + INIT = 1, + EXTERNAL_WAIT_ON_CHAIN = 2, + EXTERNAL_ON_CHAIN_FAIL = 201, + INTERNAL_WAIT_ON_CHAIN = 3, + INTERNAL_ON_CHAIN_FAIL = 301, + SUCCESS = 4, +} + +/** 充值记录状态 */ +export enum RECHARGE_RECORD_STATE { + PENDING = 1, + TO_BE_POSTED = 2, + POSTED = 3, + FAIL = 4, +} + +/** 交易信息 */ +export interface RecordTxInfo { + chainName: string + assetType: string + address: string + amount: string + txHash?: string +} + +/** 充值记录 */ +export interface RechargeRecord { + orderId: string + state: RECHARGE_RECORD_STATE + orderState: RECHARGE_ORDER_STATE_ID + createdTime: string + fromTxInfo: RecordTxInfo + toTxInfoArray: RecordTxInfo[] +} + +/** 充值记录请求 */ +export interface RechargeRecordsReqDto { + page: number + pageSize: number + internalChain?: InternalChainName + internalAddress?: string + recordState?: RECHARGE_RECORD_STATE +} + +/** 分页数据 */ +export interface PageData { + list: T[] + total: number + page: number + pageSize: number +} + +/** 充值记录响应 */ +export type RechargeRecordsResDto = PageData + +/** 充值记录详情请求 */ +export interface RechargeRecordDetailReqDto { + orderId: string +} + +/** 详细交易信息 */ +export interface RecordDetailTxInfo extends RecordTxInfo { + fee?: string + confirmations?: number + blockNumber?: number + blockTime?: string +} + +/** 充值记录详情响应 */ +export interface RechargeRecordDetailResDto { + orderId: string + state: RECHARGE_RECORD_STATE + orderState: RECHARGE_ORDER_STATE_ID + createdTime: string + fromTxInfo: RecordDetailTxInfo + toTxInfos: Record +} + +/** 重试请求 */ +export interface RetryOnChainReqDto { + orderId: string +} diff --git a/miniapps/forge/src/hooks/index.ts b/miniapps/forge/src/hooks/index.ts new file mode 100644 index 00000000..25a5f32a --- /dev/null +++ b/miniapps/forge/src/hooks/index.ts @@ -0,0 +1,14 @@ +/** + * Hooks Module Exports + */ + +export { useRechargeConfig, type ForgeOption, type RechargeConfigState } from './useRechargeConfig' +export { useForge, type ForgeParams, type ForgeState, type ForgeStep } from './useForge' +export { useContractPool, type ContractPoolState } from './useContractPool' +export { + useRechargeRecords, + useRechargeRecordDetail, + type RechargeRecordsState, + type FetchRecordsParams, + type RecordDetailState, +} from './useRechargeRecords' diff --git a/miniapps/forge/src/hooks/useContractPool.ts b/miniapps/forge/src/hooks/useContractPool.ts new file mode 100644 index 00000000..a9ab31f7 --- /dev/null +++ b/miniapps/forge/src/hooks/useContractPool.ts @@ -0,0 +1,46 @@ +/** + * Hook for fetching contract pool statistics + */ + +import { useState, useEffect, useCallback } from 'react' +import { rechargeApi } from '@/api' +import type { InternalChainName, RechargeContractPoolItem } from '@/api/types' + +export interface ContractPoolState { + poolInfo: RechargeContractPoolItem[] + isLoading: boolean + error: string | null +} + +export function useContractPool(internalChainName?: InternalChainName) { + const [state, setState] = useState({ + poolInfo: [], + isLoading: false, + error: null, + }) + + const fetchPoolInfo = useCallback(async () => { + if (!internalChainName) return + + setState((s) => ({ ...s, isLoading: true, error: null })) + try { + const res = await rechargeApi.getContractPoolInfo({ internalChainName }) + setState({ poolInfo: res.poolInfo, isLoading: false, error: null }) + } catch (err) { + setState({ + poolInfo: [], + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to load pool info', + }) + } + }, [internalChainName]) + + useEffect(() => { + fetchPoolInfo() + }, [fetchPoolInfo]) + + return { + ...state, + refetch: fetchPoolInfo, + } +} diff --git a/miniapps/forge/src/hooks/useForge.ts b/miniapps/forge/src/hooks/useForge.ts new file mode 100644 index 00000000..f028d6f4 --- /dev/null +++ b/miniapps/forge/src/hooks/useForge.ts @@ -0,0 +1,174 @@ +/** + * Hook for forge (recharge) operations + */ + +import { useState, useCallback } from 'react' +import type { BioAccount, BioSignedTransaction } from '@biochain/bio-sdk' +import { rechargeApi } from '@/api' +import { encodeRechargeV2ToTrInfoData, createRechargeMessage } from '@/api/helpers' +import type { + ExternalChainName, + FromTrJson, + RechargeV2ReqDto, + SignatureInfo, +} from '@/api/types' + +export type ForgeStep = 'idle' | 'signing_external' | 'signing_internal' | 'submitting' | 'success' | 'error' + +export interface ForgeState { + step: ForgeStep + orderId: string | null + error: string | null +} + +export interface ForgeParams { + /** 外链名称 */ + externalChain: ExternalChainName + /** 外链资产类型 */ + externalAsset: string + /** 外链转账地址(depositAddress) */ + depositAddress: string + /** 转账金额 */ + amount: string + /** 外链账户(已连接) */ + externalAccount: BioAccount + /** 内链名称 */ + internalChain: string + /** 内链资产类型 */ + internalAsset: string + /** 内链账户(接收锻造产物) */ + internalAccount: BioAccount +} + +/** + * Build FromTrJson from signed transaction + */ +function buildFromTrJson(chain: ExternalChainName, signedTx: BioSignedTransaction): FromTrJson { + const signTransData = typeof signedTx.data === 'string' + ? signedTx.data + : JSON.stringify(signedTx.data) + + switch (chain) { + case 'ETH': + return { eth: { signTransData } } + case 'BSC': + return { bsc: { signTransData } } + case 'TRON': + return { tron: signedTx.data } + default: + throw new Error(`Unsupported chain: ${chain}`) + } +} + +export function useForge() { + const [state, setState] = useState({ + step: 'idle', + orderId: null, + error: null, + }) + + const reset = useCallback(() => { + setState({ step: 'idle', orderId: null, error: null }) + }, []) + + const forge = useCallback(async (params: ForgeParams) => { + const { + externalChain, + externalAsset, + depositAddress, + amount, + externalAccount, + internalChain, + internalAsset, + internalAccount, + } = params + + if (!window.bio) { + setState({ step: 'error', orderId: null, error: 'Bio SDK not available' }) + return + } + + try { + // Step 1: Create and sign external chain transaction + setState({ step: 'signing_external', orderId: null, error: null }) + + const unsignedTx = await window.bio.request({ + method: 'bio_createTransaction', + params: [{ + from: externalAccount.address, + to: depositAddress, + amount, + chain: externalChain.toLowerCase(), + asset: externalAsset, + }], + }) + + const signedTx = await window.bio.request({ + method: 'bio_signTransaction', + params: [{ + from: externalAccount.address, + chain: externalChain.toLowerCase(), + unsignedTx, + }], + }) + + // Step 2: Sign internal chain message + setState({ step: 'signing_internal', orderId: null, error: null }) + + const rechargeMessage = createRechargeMessage({ + chainName: internalChain, + address: internalAccount.address, + assetType: internalAsset, + }) + + const messageToSign = encodeRechargeV2ToTrInfoData({ + chainName: rechargeMessage.chainName, + address: rechargeMessage.address, + timestamp: rechargeMessage.timestamp, + }) + + const signature = await window.bio.request({ + method: 'bio_signMessage', + params: [{ + message: messageToSign, + address: internalAccount.address, + }], + }) + + // Build signature info + // Note: publicKey format needs to be confirmed with backend + const signatureInfo: SignatureInfo = { + timestamp: rechargeMessage.timestamp, + signature, + publicKey: internalAccount.address, // TODO: Get actual public key + } + + // Step 3: Submit recharge request + setState({ step: 'submitting', orderId: null, error: null }) + + const fromTrJson = buildFromTrJson(externalChain, signedTx) + + const reqData: RechargeV2ReqDto = { + fromTrJson, + message: rechargeMessage, + signatureInfo, + } + + const res = await rechargeApi.submitRecharge(reqData) + + setState({ step: 'success', orderId: res.orderId, error: null }) + } catch (err) { + setState({ + step: 'error', + orderId: null, + error: err instanceof Error ? err.message : 'Forge failed', + }) + } + }, []) + + return { + ...state, + forge, + reset, + } +} diff --git a/miniapps/forge/src/hooks/useRechargeConfig.ts b/miniapps/forge/src/hooks/useRechargeConfig.ts new file mode 100644 index 00000000..0015b0bf --- /dev/null +++ b/miniapps/forge/src/hooks/useRechargeConfig.ts @@ -0,0 +1,90 @@ +/** + * Hook for fetching recharge configuration + */ + +import { useState, useEffect, useCallback } from 'react' +import { rechargeApi } from '@/api' +import type { RechargeConfig, ExternalChainName, ExternalAssetInfoItem } from '@/api/types' + +export interface RechargeConfigState { + config: RechargeConfig | null + isLoading: boolean + error: string | null +} + +export interface ForgeOption { + /** 外链名称 */ + externalChain: ExternalChainName + /** 外链资产类型 */ + externalAsset: string + /** 外链资产信息 */ + externalInfo: ExternalAssetInfoItem + /** 内链名称 */ + internalChain: string + /** 内链资产类型 */ + internalAsset: string + /** Logo */ + logo?: string +} + +/** + * Parse recharge config to forge options + */ +function parseForgeOptions(config: RechargeConfig): ForgeOption[] { + const options: ForgeOption[] = [] + + for (const [internalChain, assets] of Object.entries(config)) { + for (const [internalAsset, item] of Object.entries(assets)) { + if (!item.enable) continue + + for (const [externalChain, externalInfo] of Object.entries(item.supportChain)) { + if (!externalInfo?.enable) continue + + options.push({ + externalChain: externalChain as ExternalChainName, + externalAsset: externalInfo.assetType, + externalInfo, + internalChain, + internalAsset, + logo: item.logo || externalInfo.logo, + }) + } + } + } + + return options +} + +export function useRechargeConfig() { + const [state, setState] = useState({ + config: null, + isLoading: true, + error: null, + }) + + const fetchConfig = useCallback(async () => { + setState((s) => ({ ...s, isLoading: true, error: null })) + try { + const res = await rechargeApi.getSupport() + setState({ config: res.recharge, isLoading: false, error: null }) + } catch (err) { + setState({ + config: null, + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to load config', + }) + } + }, []) + + useEffect(() => { + fetchConfig() + }, [fetchConfig]) + + const forgeOptions = state.config ? parseForgeOptions(state.config) : [] + + return { + ...state, + forgeOptions, + refetch: fetchConfig, + } +} diff --git a/miniapps/forge/src/hooks/useRechargeRecords.ts b/miniapps/forge/src/hooks/useRechargeRecords.ts new file mode 100644 index 00000000..ed28f5b1 --- /dev/null +++ b/miniapps/forge/src/hooks/useRechargeRecords.ts @@ -0,0 +1,122 @@ +/** + * Hook for fetching recharge records + */ + +import { useState, useCallback } from 'react' +import { rechargeApi } from '@/api' +import type { + InternalChainName, + RECHARGE_RECORD_STATE, + RechargeRecord, + RechargeRecordDetailResDto, +} from '@/api/types' + +export interface RechargeRecordsState { + records: RechargeRecord[] + total: number + isLoading: boolean + error: string | null +} + +export interface FetchRecordsParams { + page?: number + pageSize?: number + internalChain?: InternalChainName + internalAddress?: string + recordState?: RECHARGE_RECORD_STATE +} + +export function useRechargeRecords() { + const [state, setState] = useState({ + records: [], + total: 0, + isLoading: false, + error: null, + }) + + const fetchRecords = useCallback(async (params: FetchRecordsParams = {}) => { + setState((s) => ({ ...s, isLoading: true, error: null })) + try { + const res = await rechargeApi.getRecords({ + page: params.page ?? 1, + pageSize: params.pageSize ?? 20, + internalChain: params.internalChain, + internalAddress: params.internalAddress, + recordState: params.recordState, + }) + setState({ + records: res.list, + total: res.total, + isLoading: false, + error: null, + }) + } catch (err) { + setState({ + records: [], + total: 0, + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to load records', + }) + } + }, []) + + return { + ...state, + fetchRecords, + } +} + +export interface RecordDetailState { + detail: RechargeRecordDetailResDto | null + isLoading: boolean + error: string | null +} + +export function useRechargeRecordDetail() { + const [state, setState] = useState({ + detail: null, + isLoading: false, + error: null, + }) + + const fetchDetail = useCallback(async (orderId: string) => { + setState((s) => ({ ...s, isLoading: true, error: null })) + try { + const res = await rechargeApi.getRecordDetail({ orderId }) + setState({ detail: res, isLoading: false, error: null }) + } catch (err) { + setState({ + detail: null, + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to load detail', + }) + } + }, []) + + const retryExternal = useCallback(async (orderId: string) => { + try { + await rechargeApi.retryExternal({ orderId }) + await fetchDetail(orderId) + return true + } catch { + return false + } + }, [fetchDetail]) + + const retryInternal = useCallback(async (orderId: string) => { + try { + await rechargeApi.retryInternal({ orderId }) + await fetchDetail(orderId) + return true + } catch { + return false + } + }, [fetchDetail]) + + return { + ...state, + fetchDetail, + retryExternal, + retryInternal, + } +} From 0c98d917a90a339310d2833c67cbd6f5d09229ef Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:08:44 +0800 Subject: [PATCH 2/5] fix(miniapps/forge): lint fixes + i18n updates - Remove unused imports (ExternalChainName, motion, React) - Use self-closing tags for empty div elements - Add vite-env.d.ts for ImportMeta.env types - Update i18n translations for forge functionality - Fix i18n tests for new translation keys --- miniapps/forge/src/App.test.tsx | 12 ++-- miniapps/forge/src/App.tsx | 1 - miniapps/forge/src/api/config.ts | 2 +- .../forge/src/components/BackgroundBeams.tsx | 10 ++-- miniapps/forge/src/i18n/index.test.ts | 2 +- miniapps/forge/src/i18n/locales/en.json | 56 ++++++++++--------- miniapps/forge/src/i18n/locales/zh-CN.json | 56 ++++++++++--------- miniapps/forge/src/i18n/locales/zh-TW.json | 56 ++++++++++--------- miniapps/forge/src/i18n/locales/zh.json | 56 ++++++++++--------- miniapps/forge/src/vite-env.d.ts | 9 +++ 10 files changed, 138 insertions(+), 122 deletions(-) create mode 100644 miniapps/forge/src/vite-env.d.ts diff --git a/miniapps/forge/src/App.test.tsx b/miniapps/forge/src/App.test.tsx index 714cd9b9..9409b556 100644 --- a/miniapps/forge/src/App.test.tsx +++ b/miniapps/forge/src/App.test.tsx @@ -118,7 +118,7 @@ describe('Forge App', () => { }) }) - it('should call bio_selectAccount on connect with chain param', async () => { + it('should call bio_selectAccount on connect', async () => { mockBio.request.mockResolvedValue({ address: '0x123', chain: 'eth' }) render() @@ -130,10 +130,12 @@ describe('Forge App', () => { fireEvent.click(screen.getByRole('button', { name: '连接钱包' })) await waitFor(() => { - expect(mockBio.request).toHaveBeenCalledWith({ - method: 'bio_selectAccount', - params: [{ chain: 'eth' }], - }) + // Should call bio_selectAccount at least once (for external and internal accounts) + expect(mockBio.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'bio_selectAccount', + }) + ) }) }) }) diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index 639b0d0a..63633d3b 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -13,7 +13,6 @@ import { cn } from '@/lib/utils' import { Coins, Leaf, DollarSign, X, ChevronDown, ChevronLeft, Zap, ArrowDown, Check, Loader2, AlertCircle } from 'lucide-react' import { useRechargeConfig, useForge, type ForgeOption } from '@/hooks' -import type { ExternalChainName } from '@/api/types' type Step = 'connect' | 'swap' | 'confirm' | 'processing' | 'success' diff --git a/miniapps/forge/src/api/config.ts b/miniapps/forge/src/api/config.ts index f79c06d2..1cafd7e1 100644 --- a/miniapps/forge/src/api/config.ts +++ b/miniapps/forge/src/api/config.ts @@ -4,7 +4,7 @@ */ /** API Base URL - to be configured via environment or runtime */ -export const API_BASE_URL = (import.meta as unknown as { env: Record }).env.VITE_COT_API_BASE_URL || 'https://api.eth-metaverse.com' +export const API_BASE_URL = import.meta.env.VITE_COT_API_BASE_URL || 'https://api.eth-metaverse.com' /** API Endpoints */ export const API_ENDPOINTS = { diff --git a/miniapps/forge/src/components/BackgroundBeams.tsx b/miniapps/forge/src/components/BackgroundBeams.tsx index b1e51b10..473a7f95 100644 --- a/miniapps/forge/src/components/BackgroundBeams.tsx +++ b/miniapps/forge/src/components/BackgroundBeams.tsx @@ -1,7 +1,5 @@ "use client"; -import React from "react"; import { cn } from "@/lib/utils"; -import { motion } from "framer-motion"; export const BackgroundBeams = ({ className }: { className?: string }) => { return ( @@ -11,12 +9,12 @@ export const BackgroundBeams = ({ className }: { className?: string }) => { className )} > -
-
+
+
-
-
+
+
diff --git a/miniapps/forge/src/i18n/index.test.ts b/miniapps/forge/src/i18n/index.test.ts index f4b2eb71..6ed30582 100644 --- a/miniapps/forge/src/i18n/index.test.ts +++ b/miniapps/forge/src/i18n/index.test.ts @@ -66,7 +66,7 @@ describe('forge i18n', () => { }) it('all locales have required keys', () => { - const requiredKeys = ['app', 'connect', 'swap', 'confirm', 'success', 'error', 'token'] + const requiredKeys = ['app', 'connect', 'forge', 'processing', 'success', 'error', 'picker', 'chain'] for (const key of requiredKeys) { expect(en).toHaveProperty(key) expect(zhCN).toHaveProperty(key) diff --git a/miniapps/forge/src/i18n/locales/en.json b/miniapps/forge/src/i18n/locales/en.json index 8fb46aa0..02ef9614 100644 --- a/miniapps/forge/src/i18n/locales/en.json +++ b/miniapps/forge/src/i18n/locales/en.json @@ -1,47 +1,49 @@ { "app": { - "title": "Exchange Center", - "subtitle": "Multi-Chain Swap", - "description": "Swap between different tokens safely and quickly" + "title": "Forge", + "subtitle": "Multi-Chain Furnace", + "description": "Forge external chain assets into Bio ecosystem tokens" }, "connect": { "button": "Connect Wallet", "loading": "Connecting..." }, - "swap": { + "forge": { "pay": "Pay", "receive": "Receive", - "balance": "Balance", - "rate": "Exchange Rate", - "button": "Swap", - "confirm": "Confirm Swap", - "processing": "Processing...", - "max": "Max" + "ratio": "Exchange Ratio", + "depositAddress": "Deposit Address", + "network": "Network", + "preview": "Preview Transaction", + "confirm": "Confirm Forge", + "continue": "Continue Forging" }, - "confirm": { - "title": "Confirm Swap", - "from": "Pay", - "to": "Receive", - "rate": "Rate", - "fee": "Network Fee", - "feeEstimate": "Estimated", - "button": "Confirm", - "cancel": "Cancel" + "processing": { + "signingExternal": "Signing external transaction...", + "signingInternal": "Signing internal message...", + "submitting": "Submitting forge request...", + "default": "Forging...", + "hint": "Please confirm in your wallet" }, "success": { - "title": "Swap Successful!", - "message": "Your swap has been submitted", - "txWait": "Transaction confirmation may take a few minutes", - "done": "Done" + "title": "Forge Complete", + "orderId": "Order" }, "error": { "sdkNotInit": "Bio SDK not initialized", "connectionFailed": "Connection failed", "invalidAmount": "Please enter a valid amount", - "insufficientBalance": "Insufficient balance" + "forgeFailed": "Forge failed" }, - "token": { - "select": "Select Token", - "search": "Search tokens" + "picker": { + "title": "Select Forge Token", + "selected": "Selected" + }, + "chain": { + "ETH": "Ethereum", + "BSC": "BNB Chain", + "TRON": "Tron", + "bfmeta": "BFMeta", + "bfchain": "BFChain" } } diff --git a/miniapps/forge/src/i18n/locales/zh-CN.json b/miniapps/forge/src/i18n/locales/zh-CN.json index 1c60e36f..932d8dc5 100644 --- a/miniapps/forge/src/i18n/locales/zh-CN.json +++ b/miniapps/forge/src/i18n/locales/zh-CN.json @@ -1,47 +1,49 @@ { "app": { - "title": "兑换中心", - "subtitle": "多链兑换", - "description": "安全、快速地在不同代币之间进行兑换" + "title": "锻造", + "subtitle": "多链熔炉", + "description": "将其他链资产锻造为 Bio 生态代币" }, "connect": { "button": "连接钱包", "loading": "连接中..." }, - "swap": { + "forge": { "pay": "支付", "receive": "获得", - "balance": "余额", - "rate": "兑换比率", - "button": "兑换", - "confirm": "确认兑换", - "processing": "处理中...", - "max": "全部" + "ratio": "兑换比例", + "depositAddress": "充值地址", + "network": "网络", + "preview": "预览交易", + "confirm": "确认锻造", + "continue": "继续锻造" }, - "confirm": { - "title": "确认兑换", - "from": "支付", - "to": "获得", - "rate": "汇率", - "fee": "网络费用", - "feeEstimate": "预估", - "button": "确认", - "cancel": "取消" + "processing": { + "signingExternal": "签名外链交易...", + "signingInternal": "签名内链消息...", + "submitting": "提交锻造请求...", + "default": "锻造中...", + "hint": "请在钱包中确认操作" }, "success": { - "title": "兑换成功!", - "message": "您的兑换已提交", - "txWait": "交易确认可能需要几分钟", - "done": "完成" + "title": "锻造完成", + "orderId": "订单" }, "error": { "sdkNotInit": "Bio SDK 未初始化", "connectionFailed": "连接失败", "invalidAmount": "请输入有效金额", - "insufficientBalance": "余额不足" + "forgeFailed": "锻造失败" }, - "token": { - "select": "选择代币", - "search": "搜索代币" + "picker": { + "title": "选择锻造币种", + "selected": "已选" + }, + "chain": { + "ETH": "Ethereum", + "BSC": "BNB Chain", + "TRON": "Tron", + "bfmeta": "BFMeta", + "bfchain": "BFChain" } } diff --git a/miniapps/forge/src/i18n/locales/zh-TW.json b/miniapps/forge/src/i18n/locales/zh-TW.json index f2b7a145..c3513764 100644 --- a/miniapps/forge/src/i18n/locales/zh-TW.json +++ b/miniapps/forge/src/i18n/locales/zh-TW.json @@ -1,47 +1,49 @@ { "app": { - "title": "兌換中心", - "subtitle": "多鏈兌換", - "description": "安全、快速地在不同代幣之間進行兌換" + "title": "鍛造", + "subtitle": "多鏈熔爐", + "description": "將其他鏈資產鍛造為 Bio 生態代幣" }, "connect": { "button": "連接錢包", "loading": "連接中..." }, - "swap": { + "forge": { "pay": "支付", "receive": "獲得", - "balance": "餘額", - "rate": "兌換比率", - "button": "兌換", - "confirm": "確認兌換", - "processing": "處理中...", - "max": "全部" + "ratio": "兌換比例", + "depositAddress": "充值地址", + "network": "網絡", + "preview": "預覽交易", + "confirm": "確認鍛造", + "continue": "繼續鍛造" }, - "confirm": { - "title": "確認兌換", - "from": "支付", - "to": "獲得", - "rate": "匯率", - "fee": "網絡費用", - "feeEstimate": "預估", - "button": "確認", - "cancel": "取消" + "processing": { + "signingExternal": "簽名外鏈交易...", + "signingInternal": "簽名內鏈消息...", + "submitting": "提交鍛造請求...", + "default": "鍛造中...", + "hint": "請在錢包中確認操作" }, "success": { - "title": "兌換成功!", - "message": "您的兌換已提交", - "txWait": "交易確認可能需要幾分鐘", - "done": "完成" + "title": "鍛造完成", + "orderId": "訂單" }, "error": { "sdkNotInit": "Bio SDK 未初始化", "connectionFailed": "連接失敗", "invalidAmount": "請輸入有效金額", - "insufficientBalance": "餘額不足" + "forgeFailed": "鍛造失敗" }, - "token": { - "select": "選擇代幣", - "search": "搜索代幣" + "picker": { + "title": "選擇鍛造幣種", + "selected": "已選" + }, + "chain": { + "ETH": "Ethereum", + "BSC": "BNB Chain", + "TRON": "Tron", + "bfmeta": "BFMeta", + "bfchain": "BFChain" } } diff --git a/miniapps/forge/src/i18n/locales/zh.json b/miniapps/forge/src/i18n/locales/zh.json index 1c60e36f..932d8dc5 100644 --- a/miniapps/forge/src/i18n/locales/zh.json +++ b/miniapps/forge/src/i18n/locales/zh.json @@ -1,47 +1,49 @@ { "app": { - "title": "兑换中心", - "subtitle": "多链兑换", - "description": "安全、快速地在不同代币之间进行兑换" + "title": "锻造", + "subtitle": "多链熔炉", + "description": "将其他链资产锻造为 Bio 生态代币" }, "connect": { "button": "连接钱包", "loading": "连接中..." }, - "swap": { + "forge": { "pay": "支付", "receive": "获得", - "balance": "余额", - "rate": "兑换比率", - "button": "兑换", - "confirm": "确认兑换", - "processing": "处理中...", - "max": "全部" + "ratio": "兑换比例", + "depositAddress": "充值地址", + "network": "网络", + "preview": "预览交易", + "confirm": "确认锻造", + "continue": "继续锻造" }, - "confirm": { - "title": "确认兑换", - "from": "支付", - "to": "获得", - "rate": "汇率", - "fee": "网络费用", - "feeEstimate": "预估", - "button": "确认", - "cancel": "取消" + "processing": { + "signingExternal": "签名外链交易...", + "signingInternal": "签名内链消息...", + "submitting": "提交锻造请求...", + "default": "锻造中...", + "hint": "请在钱包中确认操作" }, "success": { - "title": "兑换成功!", - "message": "您的兑换已提交", - "txWait": "交易确认可能需要几分钟", - "done": "完成" + "title": "锻造完成", + "orderId": "订单" }, "error": { "sdkNotInit": "Bio SDK 未初始化", "connectionFailed": "连接失败", "invalidAmount": "请输入有效金额", - "insufficientBalance": "余额不足" + "forgeFailed": "锻造失败" }, - "token": { - "select": "选择代币", - "search": "搜索代币" + "picker": { + "title": "选择锻造币种", + "selected": "已选" + }, + "chain": { + "ETH": "Ethereum", + "BSC": "BNB Chain", + "TRON": "Tron", + "bfmeta": "BFMeta", + "bfchain": "BFChain" } } diff --git a/miniapps/forge/src/vite-env.d.ts b/miniapps/forge/src/vite-env.d.ts new file mode 100644 index 00000000..4e934f5d --- /dev/null +++ b/miniapps/forge/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_COT_API_BASE_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} From 03e3ef8a25ad60bf8d745dbf9d1394d08c288fce Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:14:44 +0800 Subject: [PATCH 3/5] feat(miniapps/forge): integrate i18n in App.tsx - Add useTranslation hook - Replace hardcoded Chinese strings with t() calls - Add getChainName helper using translations - Initialize i18n in tests --- miniapps/forge/src/App.test.tsx | 3 ++ miniapps/forge/src/App.tsx | 75 ++++++++++++++++----------------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/miniapps/forge/src/App.test.tsx b/miniapps/forge/src/App.test.tsx index 9409b556..9a49be4a 100644 --- a/miniapps/forge/src/App.test.tsx +++ b/miniapps/forge/src/App.test.tsx @@ -2,6 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import App from './App' +// Initialize i18n before tests +import './i18n' + // Mock the API module vi.mock('@/api', () => ({ rechargeApi: { diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index 63633d3b..38097037 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' import type { BioAccount } from '@biochain/bio-sdk' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' @@ -25,15 +26,8 @@ const TOKEN_COLORS: Record = { BFC: 'bg-blue-600', } -const CHAIN_NAMES: Record = { - ETH: 'Ethereum', - BSC: 'BNB Chain', - TRON: 'Tron', - bfmeta: 'BFMeta', - bfchain: 'BFChain', -} - export default function App() { + const { t } = useTranslation() const [step, setStep] = useState('connect') const [externalAccount, setExternalAccount] = useState(null) const [internalAccount, setInternalAccount] = useState(null) @@ -49,6 +43,11 @@ export default function App() { // Forge hook const forgeHook = useForge() + // Helper to get chain name from translations + const getChainName = useCallback((chain: string) => { + return t(`chain.${chain}`, { defaultValue: chain }) + }, [t]) + // Close splash screen useEffect(() => { window.bio?.request({ method: 'bio_closeSplashScreen' }) @@ -167,7 +166,7 @@ export default function App() { )}

- 锻造 + {t('app.title')}

@@ -225,8 +224,8 @@ export default function App() {
-

多链熔炉

-

将其他链资产锻造为 Bio 生态代币

+

{t('app.subtitle')}

+

{t('app.description')}

{/* Available chains preview */} @@ -234,7 +233,7 @@ export default function App() {
{Object.keys(groupedOptions).map((chain) => ( - {CHAIN_NAMES[chain] || chain} + {getChainName(chain)} ))}
@@ -247,7 +246,7 @@ export default function App() { disabled={loading || forgeOptions.length === 0} > {loading && } - {loading ? '连接中...' : '连接钱包'} + {loading ? t('connect.loading') : t('connect.button')} )} @@ -265,7 +264,7 @@ export default function App() {
- 支付 ({CHAIN_NAMES[selectedOption.externalChain] || selectedOption.externalChain}) + {t('forge.pay')} ({getChainName(selectedOption.externalChain)}) {externalAccount?.address?.slice(0, 8)}... @@ -306,7 +305,7 @@ export default function App() {
- 获得 ({CHAIN_NAMES[selectedOption.internalChain] || selectedOption.internalChain}) + {t('forge.receive')} ({getChainName(selectedOption.internalChain)}) {internalAccount?.address?.slice(0, 8)}... @@ -330,11 +329,11 @@ export default function App() {
- 兑换比例 + {t('forge.ratio')} 1:1
- 充值地址 + {t('forge.depositAddress')} {selectedOption.externalInfo.depositAddress.slice(0, 10)}... @@ -349,7 +348,7 @@ export default function App() { onClick={handlePreview} disabled={!amount || parseFloat(amount) <= 0} > - 预览交易 + {t('forge.preview')}
@@ -368,7 +367,7 @@ export default function App() {
- 支付 ({CHAIN_NAMES[selectedOption.externalChain] || selectedOption.externalChain}) + {t('forge.pay')} ({getChainName(selectedOption.externalChain)})
@@ -384,7 +383,7 @@ export default function App() {
- 获得 ({CHAIN_NAMES[selectedOption.internalChain] || selectedOption.internalChain}) + {t('forge.receive')} ({getChainName(selectedOption.internalChain)})
@@ -397,21 +396,21 @@ export default function App() {
- 兑换比例 + {t('forge.ratio')} 1:1
- 网络 + {t('forge.network')}
- {selectedOption.externalChain} + {getChainName(selectedOption.externalChain)} - {selectedOption.internalChain} + {getChainName(selectedOption.internalChain)}
- 充值地址 + {t('forge.depositAddress')} {selectedOption.externalInfo.depositAddress.slice(0, 10)}... @@ -425,7 +424,7 @@ export default function App() { onClick={handleConfirm} disabled={loading} > - 确认锻造 + {t('forge.confirm')}
@@ -445,12 +444,12 @@ export default function App() {

- {forgeHook.step === 'signing_external' && '签名外链交易...'} - {forgeHook.step === 'signing_internal' && '签名内链消息...'} - {forgeHook.step === 'submitting' && '提交锻造请求...'} - {forgeHook.step === 'idle' && '锻造中...'} + {forgeHook.step === 'signing_external' && t('processing.signingExternal')} + {forgeHook.step === 'signing_internal' && t('processing.signingInternal')} + {forgeHook.step === 'submitting' && t('processing.submitting')} + {forgeHook.step === 'idle' && t('processing.default')}

-

请在钱包中确认操作

+

{t('processing.hint')}

)} @@ -469,18 +468,18 @@ export default function App() {
-

锻造完成

+

{t('success.title')}

{amount} {selectedOption.internalAsset}

{forgeHook.orderId && (

- 订单: {forgeHook.orderId.slice(0, 16)}... + {t('success.orderId')}: {forgeHook.orderId.slice(0, 16)}...

)}
)} @@ -495,7 +494,7 @@ export default function App() { />
- 选择锻造币种 + {t('picker.title')} @@ -504,7 +503,7 @@ export default function App() { {Object.entries(groupedOptions).map(([chain, options]) => (

- {CHAIN_NAMES[chain] || chain} + {getChainName(chain)}

{options.map((option) => ( @@ -525,12 +524,12 @@ export default function App() { {option.externalAsset} → {option.internalAsset} - {option.externalChain} → {CHAIN_NAMES[option.internalChain] || option.internalChain} + {getChainName(option.externalChain)} → {getChainName(option.internalChain)}
{selectedOption?.externalAsset === option.externalAsset && selectedOption?.externalChain === option.externalChain && ( - 已选 + {t('picker.selected')} )} From 44fbc3ad1f7e999ac855d04f0d977db51225fa00 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:20:54 +0800 Subject: [PATCH 4/5] test(miniapps/forge): add comprehensive tests Unit tests: - useRechargeConfig hook (config fetching, parsing, error handling) - useForge hook (forge flow, step transitions, error states) - API helpers (encoding functions, message creation) Storybook tests: - App.stories.tsx with interactive play functions - ConnectStep, SwapStep, TokenPicker, LoadingState, ErrorState stories E2E tests: - Updated ui.spec.ts with 9 comprehensive test cases - Full forge flow, error handling, navigation, token selection - Updated i18n helpers for forge-specific text patterns --- miniapps/forge/e2e/helpers/i18n.ts | 38 +- miniapps/forge/e2e/ui.spec.ts | 179 ++++++++- miniapps/forge/src/App.stories.tsx | 362 ++++++++++++++++++ miniapps/forge/src/api/helpers.test.ts | 119 ++++++ miniapps/forge/src/hooks/useForge.test.ts | 215 +++++++++++ .../forge/src/hooks/useRechargeConfig.test.ts | 178 +++++++++ 6 files changed, 1061 insertions(+), 30 deletions(-) create mode 100644 miniapps/forge/src/App.stories.tsx create mode 100644 miniapps/forge/src/api/helpers.test.ts create mode 100644 miniapps/forge/src/hooks/useForge.test.ts create mode 100644 miniapps/forge/src/hooks/useRechargeConfig.test.ts diff --git a/miniapps/forge/e2e/helpers/i18n.ts b/miniapps/forge/e2e/helpers/i18n.ts index 007f32f2..c4ee6f31 100644 --- a/miniapps/forge/e2e/helpers/i18n.ts +++ b/miniapps/forge/e2e/helpers/i18n.ts @@ -5,29 +5,39 @@ import type { Page, Locator } from '@playwright/test' export const UI_TEXT = { + app: { + title: { source: '锻造', pattern: /锻造|Forge/i }, + subtitle: { source: '多链熔炉', pattern: /多链熔炉|Multi-chain Forge/i }, + }, connect: { - button: /连接钱包|Connect Wallet/i, - loading: /连接中|Connecting/i, + button: { source: '连接钱包', pattern: /连接钱包|Connect Wallet/i }, + loading: { source: '连接中', pattern: /连接中|Connecting/i }, }, swap: { - pay: /支付|Pay/i, - receive: /获得|Receive/i, - button: /兑换|Swap/i, - confirm: /确认兑换|Confirm Swap/i, - preview: /预览交易|Preview|预览/i, - max: /全部|Max/i, + pay: { source: '支付', pattern: /支付|Pay/i }, + receive: { source: '获得', pattern: /获得|Receive/i }, + button: { source: '兑换', pattern: /兑换|Swap/i }, + preview: { source: '预览交易', pattern: /预览交易|Preview/i }, }, confirm: { - title: /确认兑换|Confirm Swap/i, - button: /确认|Confirm/i, - cancel: /取消|Cancel/i, + title: { source: '确认锻造', pattern: /确认锻造|Confirm Forge/i }, + button: { source: '确认锻造', pattern: /确认锻造|Confirm/i }, }, success: { - title: /兑换成功|Swap Successful/i, - done: /完成|Done/i, + title: { source: '锻造完成', pattern: /锻造完成|Forge Complete/i }, + continue: { source: '继续锻造', pattern: /继续锻造|Continue/i }, }, token: { - select: /选择代币|Select Token/i, + select: { source: '选择锻造币种', pattern: /选择锻造币种|Select Token/i }, + selected: { source: '已选', pattern: /已选|Selected/i }, + }, + processing: { + signingExternal: { source: '签名外链交易', pattern: /签名外链交易|Signing External/i }, + signingInternal: { source: '签名内链消息', pattern: /签名内链消息|Signing Internal/i }, + submitting: { source: '提交锻造请求', pattern: /提交锻造请求|Submitting/i }, + }, + error: { + sdkNotInit: { source: 'Bio SDK 未初始化', pattern: /Bio SDK 未初始化|SDK not initialized/i }, }, } as const diff --git a/miniapps/forge/e2e/ui.spec.ts b/miniapps/forge/e2e/ui.spec.ts index 6a91df23..ad8ffc69 100644 --- a/miniapps/forge/e2e/ui.spec.ts +++ b/miniapps/forge/e2e/ui.spec.ts @@ -1,11 +1,59 @@ import { test, expect } from '@playwright/test' -import { UI_TEXT, TEST_IDS, byTestId } from './helpers/i18n' +import { UI_TEXT } from './helpers/i18n' + +const mockApiResponses = ` + // Mock fetch for API calls + const originalFetch = window.fetch + window.fetch = async (url, options) => { + if (url.includes('getSupport')) { + return { + ok: true, + json: () => Promise.resolve({ + recharge: { + bfmeta: { + BFM: { + enable: true, + logo: '', + supportChain: { + ETH: { enable: true, assetType: 'ETH', depositAddress: '0x1234567890', logo: '' }, + BSC: { enable: true, assetType: 'BNB', depositAddress: '0xabcdef1234', logo: '' }, + }, + }, + }, + }, + }), + } + } + if (url.includes('rechargeV2')) { + return { + ok: true, + json: () => Promise.resolve({ orderId: 'order-123456' }), + } + } + return originalFetch(url, options) + } +` const mockBioSDK = ` window.bio = { - request: async ({ method }) => { + request: async ({ method, params }) => { + if (method === 'bio_closeSplashScreen') return {} if (method === 'bio_selectAccount') { - return { address: '0x1234...5678', name: 'Test Wallet' } + const chain = params?.[0]?.chain || 'eth' + return { + address: chain === 'bfmeta' ? 'bfmeta1234567890' : '0x1234567890abcdef1234567890abcdef12345678', + chain, + name: 'Test Wallet' + } + } + if (method === 'bio_createTransaction') { + return { txHash: 'unsigned-tx-123' } + } + if (method === 'bio_signTransaction') { + return { data: '0xsigned-tx-data-456' } + } + if (method === 'bio_signMessage') { + return 'signature-789' } return {} } @@ -15,51 +63,69 @@ const mockBioSDK = ` test.describe('Forge UI', () => { test.beforeEach(async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }) + await page.addInitScript(mockApiResponses) }) - test('01 - connect page', async ({ page }) => { + test('01 - connect page shows welcome screen', async ({ page }) => { await page.goto('/') await page.waitForLoadState('networkidle') + + // Should show title and subtitle + await expect(page.locator(`text=${UI_TEXT.app.subtitle.source}`)).toBeVisible() await expect(page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first()).toBeVisible() + await expect(page).toHaveScreenshot('01-connect.png') }) - test('02 - swap page after connect', async ({ page }) => { + test('02 - swap page after wallet connect', async ({ page }) => { await page.addInitScript(mockBioSDK) await page.goto('/') await page.waitForLoadState('networkidle') + // Click connect button await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await expect(page.locator(`button:has-text("${UI_TEXT.swap.button.source}")`).first()).toBeVisible() + + // Should show swap UI with pay/receive + await expect(page.locator(`text=${UI_TEXT.swap.pay.source}`).first()).toBeVisible({ timeout: 10000 }) + await expect(page.locator(`text=${UI_TEXT.swap.receive.source}`).first()).toBeVisible() await expect(page).toHaveScreenshot('02-swap.png') }) - test('03 - swap page with amount', async ({ page }) => { + test('03 - swap page with amount entered', async ({ page }) => { await page.addInitScript(mockBioSDK) await page.goto('/') await page.waitForLoadState('networkidle') await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await page.waitForSelector('input[type="number"]') + await page.waitForSelector('input[type="number"]', { timeout: 10000 }) + // Enter amount await page.fill('input[type="number"]', '1.5') await expect(page.locator('input[type="number"]')).toHaveValue('1.5') + // Preview button should be enabled + const previewButton = page.locator(`button:has-text("${UI_TEXT.swap.preview.source}")`) + await expect(previewButton).toBeEnabled() + await expect(page).toHaveScreenshot('03-swap-amount.png') }) - test('04 - token picker', async ({ page }) => { + test('04 - token picker modal', async ({ page }) => { await page.addInitScript(mockBioSDK) await page.goto('/') await page.waitForLoadState('networkidle') await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await page.waitForSelector('button:has-text("ETH")') + await page.waitForSelector('button:has-text("ETH")', { timeout: 10000 }) + // Click token selector to open picker await page.click('button:has-text("ETH")') await expect(page.locator(`text=${UI_TEXT.token.select.source}`).first()).toBeVisible() + // Should show available tokens + await expect(page.locator('text=Ethereum')).toBeVisible() + await expect(page).toHaveScreenshot('04-token-picker.png') }) @@ -69,16 +135,97 @@ test.describe('Forge UI', () => { await page.waitForLoadState('networkidle') await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() - await page.waitForSelector('input[type="number"]') + await page.waitForSelector('input[type="number"]', { timeout: 10000 }) + // Enter amount await page.fill('input[type="number"]', '1.5') - const previewButton = page.locator(`button:has-text("${UI_TEXT.swap.preview.source}")`).first() - if (await previewButton.isVisible()) { - await previewButton.click() - await expect(page.locator(`text=${UI_TEXT.confirm.title.source}`).first()).toBeVisible() - } + // Click preview + await page.locator(`button:has-text("${UI_TEXT.swap.preview.source}")`).click() + + // Should show confirm page + await expect(page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first()).toBeVisible({ timeout: 5000 }) await expect(page).toHaveScreenshot('05-confirm.png') }) + + test('06 - error state without bio SDK', async ({ page }) => { + // No bio SDK mock + await page.goto('/') + await page.waitForLoadState('networkidle') + + await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() + + // Should show error + await expect(page.locator(`text=${UI_TEXT.error.sdkNotInit.source}`)).toBeVisible({ timeout: 5000 }) + + await expect(page).toHaveScreenshot('06-error.png') + }) + + test('07 - full forge flow', async ({ page }) => { + await page.addInitScript(mockBioSDK) + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Step 1: Connect + await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() + await page.waitForSelector('input[type="number"]', { timeout: 10000 }) + + // Step 2: Enter amount + await page.fill('input[type="number"]', '0.5') + + // Step 3: Preview + await page.locator(`button:has-text("${UI_TEXT.swap.preview.source}")`).click() + await expect(page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first()).toBeVisible({ timeout: 5000 }) + + // Step 4: Confirm + await page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first().click() + + // Should show success or processing + await expect( + page.locator(`text=${UI_TEXT.success.title.source}`).or(page.locator(`text=${UI_TEXT.processing.signingExternal.source}`)) + ).toBeVisible({ timeout: 15000 }) + + await expect(page).toHaveScreenshot('07-flow-complete.png') + }) + + test('08 - back navigation from confirm', async ({ page }) => { + await page.addInitScript(mockBioSDK) + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate to confirm page + await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() + await page.waitForSelector('input[type="number"]', { timeout: 10000 }) + await page.fill('input[type="number"]', '1.0') + await page.locator(`button:has-text("${UI_TEXT.swap.preview.source}")`).click() + await expect(page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first()).toBeVisible({ timeout: 5000 }) + + // Click back button + await page.locator('button[aria-label="back"], button:has(svg.lucide-chevron-left)').first().click() + + // Should go back to swap page + await expect(page.locator('input[type="number"]')).toBeVisible() + }) + + test('09 - token selection change', async ({ page }) => { + await page.addInitScript(mockBioSDK) + await page.goto('/') + await page.waitForLoadState('networkidle') + + await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() + await page.waitForSelector('button:has-text("ETH")', { timeout: 10000 }) + + // Open picker + await page.click('button:has-text("ETH")') + await expect(page.locator(`text=${UI_TEXT.token.select.source}`)).toBeVisible() + + // Select different token (BNB on BSC) + const bnbOption = page.locator('text=BNB').first() + if (await bnbOption.isVisible()) { + await bnbOption.click() + // Picker should close and new token should be selected + await expect(page.locator('button:has-text("BNB")')).toBeVisible({ timeout: 5000 }) + } + }) }) diff --git a/miniapps/forge/src/App.stories.tsx b/miniapps/forge/src/App.stories.tsx new file mode 100644 index 00000000..ee095042 --- /dev/null +++ b/miniapps/forge/src/App.stories.tsx @@ -0,0 +1,362 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within, userEvent, fn, waitFor } from 'storybook/test' +import App from './App' + +// Mock API for stories +const mockConfig = { + bfmeta: { + BFM: { + enable: true, + logo: '', + supportChain: { + ETH: { + enable: true, + assetType: 'ETH', + depositAddress: '0x1234567890abcdef1234567890abcdef12345678', + logo: '', + }, + BSC: { + enable: true, + assetType: 'BNB', + depositAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + logo: '', + }, + }, + }, + }, +} + +// Setup mock API responses +const setupMockApi = () => { + window.fetch = fn().mockImplementation((url: string) => { + if (url.includes('getSupport')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ recharge: mockConfig }), + }) + } + if (url.includes('rechargeV2')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ orderId: 'mock-order-123' }), + }) + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) + }) +} + +const meta = { + title: 'App/ForgeApp', + component: App, + parameters: { + layout: 'fullscreen', + viewport: { + defaultViewport: 'mobile1', + }, + }, + decorators: [ + (Story) => { + setupMockApi() + return ( +
+ +
+ ) + }, + ], + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +/** + * Initial connect state - shows welcome screen + */ +export const ConnectStep: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for config to load + await waitFor( + () => { + expect(canvas.getByText('多链熔炉')).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + // Should show connect button + const connectButton = canvas.getByRole('button', { name: /连接钱包/i }) + expect(connectButton).toBeInTheDocument() + expect(connectButton).toBeEnabled() + }, +} + +/** + * Swap step - after wallet connected + */ +export const SwapStep: Story = { + decorators: [ + (Story) => { + setupMockApi() + // Mock bio SDK with connected wallet + // @ts-expect-error - mock global + window.bio = { + request: fn().mockImplementation(({ method }: { method: string }) => { + if (method === 'bio_selectAccount') { + return Promise.resolve({ + address: '0x1234567890abcdef1234567890abcdef12345678', + chain: 'eth', + }) + } + if (method === 'bio_closeSplashScreen') { + return Promise.resolve() + } + return Promise.resolve({}) + }), + } + return ( +
+ +
+ ) + }, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for connect button and click + await waitFor( + () => { + expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + const connectButton = canvas.getByRole('button', { name: /连接钱包/i }) + await userEvent.click(connectButton) + + // Should show swap UI + await waitFor( + () => { + expect(canvas.getByText(/支付/i)).toBeInTheDocument() + }, + { timeout: 5000 } + ) + }, +} + +/** + * Swap step with amount entered + */ +export const SwapWithAmount: Story = { + decorators: [ + (Story) => { + setupMockApi() + // @ts-expect-error - mock global + window.bio = { + request: fn().mockImplementation(({ method }: { method: string }) => { + if (method === 'bio_selectAccount') { + return Promise.resolve({ + address: '0x1234567890abcdef1234567890abcdef12345678', + chain: 'eth', + }) + } + if (method === 'bio_closeSplashScreen') { + return Promise.resolve() + } + return Promise.resolve({}) + }), + } + return ( +
+ +
+ ) + }, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Connect wallet first + await waitFor( + () => { + expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + await userEvent.click(canvas.getByRole('button', { name: /连接钱包/i })) + + // Wait for swap UI + await waitFor( + () => { + expect(canvas.getByRole('spinbutton')).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + // Enter amount + const input = canvas.getByRole('spinbutton') + await userEvent.clear(input) + await userEvent.type(input, '1.5') + + // Preview button should be enabled + await waitFor(() => { + const previewButton = canvas.getByRole('button', { name: /预览交易/i }) + expect(previewButton).toBeEnabled() + }) + }, +} + +/** + * Token picker modal + */ +export const TokenPicker: Story = { + decorators: [ + (Story) => { + setupMockApi() + // @ts-expect-error - mock global + window.bio = { + request: fn().mockImplementation(({ method }: { method: string }) => { + if (method === 'bio_selectAccount') { + return Promise.resolve({ + address: '0x1234567890abcdef1234567890abcdef12345678', + chain: 'eth', + }) + } + if (method === 'bio_closeSplashScreen') { + return Promise.resolve() + } + return Promise.resolve({}) + }), + } + return ( +
+ +
+ ) + }, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Connect wallet + await waitFor( + () => { + expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + await userEvent.click(canvas.getByRole('button', { name: /连接钱包/i })) + + // Wait for token selector button + await waitFor( + () => { + expect(canvas.getByText('ETH')).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + // Click token selector to open picker + const tokenButton = canvas.getAllByRole('button').find((btn) => btn.textContent?.includes('ETH')) + if (tokenButton) { + await userEvent.click(tokenButton) + } + + // Should show token picker + await waitFor(() => { + expect(canvas.getByText(/选择锻造币种/i)).toBeInTheDocument() + }) + }, +} + +/** + * Loading state while connecting + */ +export const LoadingState: Story = { + decorators: [ + (Story) => { + setupMockApi() + // Mock slow bio SDK + // @ts-expect-error - mock global + window.bio = { + request: fn().mockImplementation(({ method }: { method: string }) => { + if (method === 'bio_selectAccount') { + // Simulate slow connection + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + address: '0x123', + chain: 'eth', + }) + }, 10000) + }) + } + return Promise.resolve({}) + }), + } + return ( +
+ +
+ ) + }, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for connect button + await waitFor( + () => { + expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + // Click connect + await userEvent.click(canvas.getByRole('button', { name: /连接钱包/i })) + + // Should show loading state + await waitFor(() => { + expect(canvas.getByText(/连接中/i)).toBeInTheDocument() + }) + }, +} + +/** + * Error state - SDK not initialized + */ +export const ErrorState: Story = { + decorators: [ + (Story) => { + setupMockApi() + // No bio SDK - set to undefined + window.bio = undefined + return ( +
+ +
+ ) + }, + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for connect button + await waitFor( + () => { + expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + }, + { timeout: 5000 } + ) + + // Click connect - should show error + await userEvent.click(canvas.getByRole('button', { name: /连接钱包/i })) + + await waitFor(() => { + expect(canvas.getByText(/Bio SDK 未初始化/i)).toBeInTheDocument() + }) + }, +} diff --git a/miniapps/forge/src/api/helpers.test.ts b/miniapps/forge/src/api/helpers.test.ts new file mode 100644 index 00000000..43752c19 --- /dev/null +++ b/miniapps/forge/src/api/helpers.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + encodeTimestampMessage, + encodeRechargeV2ToTrInfoData, + createRechargeMessage, +} from './helpers' + +describe('API Helpers', () => { + describe('encodeTimestampMessage', () => { + it('should encode message with timestamp', () => { + const result = encodeTimestampMessage({ + timestamp: 1704067200000, + }) + + expect(result).toContain('1704067200000') + expect(JSON.parse(result)).toEqual({ timestamp: 1704067200000 }) + }) + + it('should return valid JSON', () => { + const result = encodeTimestampMessage({ + timestamp: 1704067200000, + }) + + expect(() => JSON.parse(result)).not.toThrow() + }) + }) + + describe('encodeRechargeV2ToTrInfoData', () => { + it('should encode recharge data correctly', () => { + const result = encodeRechargeV2ToTrInfoData({ + chainName: 'bfmeta', + address: 'BFM123456789', + timestamp: 1704067200000, + }) + + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('should produce different results for different inputs', () => { + const result1 = encodeRechargeV2ToTrInfoData({ + chainName: 'bfmeta', + address: 'addr1', + timestamp: 1000, + }) + + const result2 = encodeRechargeV2ToTrInfoData({ + chainName: 'bfmeta', + address: 'addr2', + timestamp: 1000, + }) + + expect(result1).not.toBe(result2) + }) + + it('should be deterministic for same input', () => { + const input = { + chainName: 'bfmeta', + address: 'testaddr', + timestamp: 1704067200000, + } + + const result1 = encodeRechargeV2ToTrInfoData(input) + const result2 = encodeRechargeV2ToTrInfoData(input) + + expect(result1).toBe(result2) + }) + }) + + describe('createRechargeMessage', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should create message with current timestamp', () => { + const result = createRechargeMessage({ + chainName: 'bfmeta', + address: 'testaddr', + assetType: 'BFM', + }) + + expect(result.chainName).toBe('bfmeta') + expect(result.address).toBe('testaddr') + expect(result.assetType).toBe('BFM') + expect(result.timestamp).toBe(1704067200000) + }) + + it('should use current time for timestamp', () => { + const before = Date.now() + const result = createRechargeMessage({ + chainName: 'bfchain', + address: 'addr123', + assetType: 'BFC', + }) + const after = Date.now() + + expect(result.timestamp).toBeGreaterThanOrEqual(before) + expect(result.timestamp).toBeLessThanOrEqual(after) + }) + + it('should include all required fields', () => { + const result = createRechargeMessage({ + chainName: 'bfmeta', + address: 'testaddr', + assetType: 'BFM', + }) + + expect(result).toHaveProperty('chainName') + expect(result).toHaveProperty('address') + expect(result).toHaveProperty('assetType') + expect(result).toHaveProperty('timestamp') + }) + }) +}) diff --git a/miniapps/forge/src/hooks/useForge.test.ts b/miniapps/forge/src/hooks/useForge.test.ts new file mode 100644 index 00000000..9aac03ff --- /dev/null +++ b/miniapps/forge/src/hooks/useForge.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { useForge, type ForgeParams } from './useForge' + +vi.mock('@/api', () => ({ + rechargeApi: { + submitRecharge: vi.fn(), + }, +})) + +import { rechargeApi } from '@/api' + +const mockBio = { + request: vi.fn(), +} + +const mockForgeParams: ForgeParams = { + externalChain: 'ETH', + externalAsset: 'ETH', + depositAddress: '0xdeposit123', + amount: '1.5', + externalAccount: { address: '0xexternal123', chain: 'eth' }, + internalChain: 'bfmeta', + internalAsset: 'BFM', + internalAccount: { address: 'bfmeta123', chain: 'bfmeta' }, +} + +describe('useForge', () => { + beforeEach(() => { + vi.clearAllMocks() + window.bio = mockBio as unknown as typeof window.bio + }) + + afterEach(() => { + vi.restoreAllMocks() + window.bio = undefined + }) + + it('should start with idle state', () => { + const { result } = renderHook(() => useForge()) + + expect(result.current.step).toBe('idle') + expect(result.current.orderId).toBeNull() + expect(result.current.error).toBeNull() + }) + + it('should handle forge flow successfully', async () => { + mockBio.request + .mockResolvedValueOnce({ txHash: 'unsigned123' }) // bio_createTransaction + .mockResolvedValueOnce({ data: '0xsigned123' }) // bio_signTransaction + .mockResolvedValueOnce('signature123') // bio_signMessage + + vi.mocked(rechargeApi.submitRecharge).mockResolvedValue({ orderId: 'order123' }) + + const { result } = renderHook(() => useForge()) + + act(() => { + result.current.forge(mockForgeParams) + }) + + // Should transition through states + await waitFor(() => { + expect(result.current.step).toBe('success') + }) + + expect(result.current.orderId).toBe('order123') + expect(result.current.error).toBeNull() + + // Verify API calls + expect(mockBio.request).toHaveBeenCalledTimes(3) + expect(rechargeApi.submitRecharge).toHaveBeenCalledTimes(1) + }) + + it('should handle missing bio SDK', async () => { + window.bio = undefined + + const { result } = renderHook(() => useForge()) + + act(() => { + result.current.forge(mockForgeParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('error') + }) + + expect(result.current.error).toBe('Bio SDK not available') + }) + + it('should handle transaction creation error', async () => { + mockBio.request.mockRejectedValueOnce(new Error('User rejected')) + + const { result } = renderHook(() => useForge()) + + act(() => { + result.current.forge(mockForgeParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('error') + }) + + expect(result.current.error).toBe('User rejected') + }) + + it('should handle signature error', async () => { + mockBio.request + .mockResolvedValueOnce({ txHash: 'unsigned123' }) + .mockResolvedValueOnce({ data: '0xsigned123' }) + .mockRejectedValueOnce(new Error('Signature failed')) + + const { result } = renderHook(() => useForge()) + + act(() => { + result.current.forge(mockForgeParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('error') + }) + + expect(result.current.error).toBe('Signature failed') + }) + + it('should handle submit error', async () => { + mockBio.request + .mockResolvedValueOnce({ txHash: 'unsigned123' }) + .mockResolvedValueOnce({ data: '0xsigned123' }) + .mockResolvedValueOnce('signature123') + + vi.mocked(rechargeApi.submitRecharge).mockRejectedValue(new Error('Server error')) + + const { result } = renderHook(() => useForge()) + + act(() => { + result.current.forge(mockForgeParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('error') + }) + + expect(result.current.error).toBe('Server error') + }) + + it('should reset state', async () => { + mockBio.request.mockRejectedValueOnce(new Error('Test error')) + + const { result } = renderHook(() => useForge()) + + act(() => { + result.current.forge(mockForgeParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('error') + }) + + act(() => { + result.current.reset() + }) + + expect(result.current.step).toBe('idle') + expect(result.current.orderId).toBeNull() + expect(result.current.error).toBeNull() + }) + + it('should build correct fromTrJson for ETH', async () => { + mockBio.request + .mockResolvedValueOnce({ txHash: 'unsigned' }) + .mockResolvedValueOnce({ data: '0xsignedEthTx' }) + .mockResolvedValueOnce('sig') + + vi.mocked(rechargeApi.submitRecharge).mockResolvedValue({ orderId: 'order' }) + + const { result } = renderHook(() => useForge()) + + act(() => { + result.current.forge(mockForgeParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('success') + }) + + const submitCall = vi.mocked(rechargeApi.submitRecharge).mock.calls[0][0] + expect(submitCall.fromTrJson).toHaveProperty('eth') + expect(submitCall.fromTrJson.eth?.signTransData).toBe('0xsignedEthTx') + }) + + it('should build correct fromTrJson for BSC', async () => { + mockBio.request + .mockResolvedValueOnce({ txHash: 'unsigned' }) + .mockResolvedValueOnce({ data: '0xsignedBscTx' }) + .mockResolvedValueOnce('sig') + + vi.mocked(rechargeApi.submitRecharge).mockResolvedValue({ orderId: 'order' }) + + const { result } = renderHook(() => useForge()) + + const bscParams = { ...mockForgeParams, externalChain: 'BSC' as const } + + act(() => { + result.current.forge(bscParams) + }) + + await waitFor(() => { + expect(result.current.step).toBe('success') + }) + + const submitCall = vi.mocked(rechargeApi.submitRecharge).mock.calls[0][0] + expect(submitCall.fromTrJson).toHaveProperty('bsc') + expect(submitCall.fromTrJson.bsc?.signTransData).toBe('0xsignedBscTx') + }) +}) diff --git a/miniapps/forge/src/hooks/useRechargeConfig.test.ts b/miniapps/forge/src/hooks/useRechargeConfig.test.ts new file mode 100644 index 00000000..515a70a9 --- /dev/null +++ b/miniapps/forge/src/hooks/useRechargeConfig.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { useRechargeConfig } from './useRechargeConfig' +import type { RechargeSupportResDto } from '@/api/types' + +vi.mock('@/api', () => ({ + rechargeApi: { + getSupport: vi.fn(), + }, +})) + +import { rechargeApi } from '@/api' + +const mockConfig: RechargeSupportResDto = { + recharge: { + bfmeta: { + BFM: { + enable: true, + chainName: 'bfmeta', + assetType: 'BFM', + applyAddress: 'bfm-apply-addr', + logo: 'bfm.png', + supportChain: { + ETH: { + enable: true, + assetType: 'ETH', + depositAddress: '0x123', + logo: 'eth.png', + }, + BSC: { + enable: true, + assetType: 'BNB', + depositAddress: '0x456', + }, + TRON: { + enable: false, + assetType: 'TRX', + depositAddress: 'T123', + }, + }, + }, + }, + bfchain: { + BFC: { + enable: false, + chainName: 'bfchain', + assetType: 'BFC', + applyAddress: 'bfc-apply-addr', + supportChain: {}, + }, + }, + }, +} + +describe('useRechargeConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should fetch config on mount', async () => { + vi.mocked(rechargeApi.getSupport).mockResolvedValue(mockConfig) + + const { result } = renderHook(() => useRechargeConfig()) + + expect(result.current.isLoading).toBe(true) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(rechargeApi.getSupport).toHaveBeenCalledTimes(1) + expect(result.current.config).toEqual(mockConfig.recharge) + }) + + it('should parse forge options correctly', async () => { + vi.mocked(rechargeApi.getSupport).mockResolvedValue(mockConfig) + + const { result } = renderHook(() => useRechargeConfig()) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + const options = result.current.forgeOptions + expect(options).toHaveLength(2) // ETH and BSC enabled, TRON disabled + + expect(options[0]).toMatchObject({ + externalChain: 'ETH', + externalAsset: 'ETH', + internalChain: 'bfmeta', + internalAsset: 'BFM', + }) + + expect(options[1]).toMatchObject({ + externalChain: 'BSC', + externalAsset: 'BNB', + internalChain: 'bfmeta', + internalAsset: 'BFM', + }) + }) + + it('should handle API errors', async () => { + vi.mocked(rechargeApi.getSupport).mockRejectedValue(new Error('Network error')) + + const { result } = renderHook(() => useRechargeConfig()) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.error).toBe('Network error') + expect(result.current.config).toBeNull() + expect(result.current.forgeOptions).toHaveLength(0) + }) + + it('should refetch on demand', async () => { + vi.mocked(rechargeApi.getSupport).mockResolvedValue(mockConfig) + + const { result } = renderHook(() => useRechargeConfig()) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(rechargeApi.getSupport).toHaveBeenCalledTimes(1) + + act(() => { + result.current.refetch() + }) + + await waitFor(() => { + expect(rechargeApi.getSupport).toHaveBeenCalledTimes(2) + }) + }) + + it('should filter disabled assets', async () => { + const configWithDisabled: RechargeSupportResDto = { + recharge: { + bfmeta: { + BFM: { + enable: true, + chainName: 'bfmeta', + assetType: 'BFM', + applyAddress: 'bfm-apply', + supportChain: { + ETH: { enable: true, assetType: 'ETH', depositAddress: '0x1' }, + BSC: { enable: false, assetType: 'BNB', depositAddress: '0x2' }, + }, + }, + DISABLED: { + enable: false, + chainName: 'bfmeta', + assetType: 'DISABLED', + applyAddress: 'disabled-apply', + supportChain: { + ETH: { enable: true, assetType: 'ETH', depositAddress: '0x3' }, + }, + }, + }, + }, + } + + vi.mocked(rechargeApi.getSupport).mockResolvedValue(configWithDisabled) + + const { result } = renderHook(() => useRechargeConfig()) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.forgeOptions).toHaveLength(1) + expect(result.current.forgeOptions[0].externalChain).toBe('ETH') + }) +}) From e9d86d8b28bd05f7a407761318855496b672d529 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 29 Dec 2025 22:36:27 +0800 Subject: [PATCH 5/5] fix(miniapps/forge): fix storybook tests - wait for config to load - Fixed mock API URL pattern to match actual endpoint (/recharge/support) - Updated all story play functions to wait for button to be enabled - Button is disabled until forgeOptions loads, causing CI failures --- miniapps/forge/src/App.stories.tsx | 43 ++++++++++++++++++------------ 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/miniapps/forge/src/App.stories.tsx b/miniapps/forge/src/App.stories.tsx index ee095042..4466cfaf 100644 --- a/miniapps/forge/src/App.stories.tsx +++ b/miniapps/forge/src/App.stories.tsx @@ -29,13 +29,15 @@ const mockConfig = { // Setup mock API responses const setupMockApi = () => { window.fetch = fn().mockImplementation((url: string) => { - if (url.includes('getSupport')) { + // Match /cot/recharge/support endpoint + if (url.includes('/recharge/support')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ recharge: mockConfig }), }) } - if (url.includes('rechargeV2')) { + // Match /cot/recharge/V2 endpoint + if (url.includes('/recharge/V2')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ orderId: 'mock-order-123' }), @@ -77,18 +79,20 @@ export const ConnectStep: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - // Wait for config to load + // Wait for config to load and connect button to become enabled + // The button is disabled while config is loading or when forgeOptions is empty await waitFor( () => { - expect(canvas.getByText('多链熔炉')).toBeInTheDocument() + const connectButton = canvas.getByRole('button', { name: /连接钱包/i }) + expect(connectButton).toBeEnabled() }, { timeout: 5000 } ) - // Should show connect button + // Should show title and connect button + expect(canvas.getByText('多链熔炉')).toBeInTheDocument() const connectButton = canvas.getByRole('button', { name: /连接钱包/i }) expect(connectButton).toBeInTheDocument() - expect(connectButton).toBeEnabled() }, } @@ -125,10 +129,11 @@ export const SwapStep: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - // Wait for connect button and click + // Wait for connect button to be enabled (config loaded) await waitFor( () => { - expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + const btn = canvas.getByRole('button', { name: /连接钱包/i }) + expect(btn).toBeEnabled() }, { timeout: 5000 } ) @@ -178,10 +183,11 @@ export const SwapWithAmount: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - // Connect wallet first + // Wait for connect button to be enabled (config loaded) await waitFor( () => { - expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + const btn = canvas.getByRole('button', { name: /连接钱包/i }) + expect(btn).toBeEnabled() }, { timeout: 5000 } ) @@ -241,10 +247,11 @@ export const TokenPicker: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - // Connect wallet + // Wait for connect button to be enabled (config loaded) await waitFor( () => { - expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + const btn = canvas.getByRole('button', { name: /连接钱包/i }) + expect(btn).toBeEnabled() }, { timeout: 5000 } ) @@ -307,10 +314,11 @@ export const LoadingState: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - // Wait for connect button + // Wait for connect button to be enabled (config loaded) await waitFor( () => { - expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + const btn = canvas.getByRole('button', { name: /连接钱包/i }) + expect(btn).toBeEnabled() }, { timeout: 5000 } ) @@ -344,15 +352,16 @@ export const ErrorState: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - // Wait for connect button + // Wait for connect button to be enabled (config loaded) await waitFor( () => { - expect(canvas.getByRole('button', { name: /连接钱包/i })).toBeInTheDocument() + const btn = canvas.getByRole('button', { name: /连接钱包/i }) + expect(btn).toBeEnabled() }, { timeout: 5000 } ) - // Click connect - should show error + // Click connect - should show error (Bio SDK not initialized) await userEvent.click(canvas.getByRole('button', { name: /连接钱包/i })) await waitFor(() => {