diff --git a/miniapps/forge/e2e/helpers/i18n.ts b/miniapps/forge/e2e/helpers/i18n.ts
index 007f32f21..c4ee6f31a 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 6a91df23f..ad8ffc696 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 000000000..4466cfafc
--- /dev/null
+++ b/miniapps/forge/src/App.stories.tsx
@@ -0,0 +1,371 @@
+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) => {
+ // Match /cot/recharge/support endpoint
+ if (url.includes('/recharge/support')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ recharge: mockConfig }),
+ })
+ }
+ // Match /cot/recharge/V2 endpoint
+ if (url.includes('/recharge/V2')) {
+ 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 and connect button to become enabled
+ // The button is disabled while config is loading or when forgeOptions is empty
+ await waitFor(
+ () => {
+ const connectButton = canvas.getByRole('button', { name: /连接钱包/i })
+ expect(connectButton).toBeEnabled()
+ },
+ { timeout: 5000 }
+ )
+
+ // Should show title and connect button
+ expect(canvas.getByText('多链熔炉')).toBeInTheDocument()
+ const connectButton = canvas.getByRole('button', { name: /连接钱包/i })
+ expect(connectButton).toBeInTheDocument()
+ },
+}
+
+/**
+ * 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 to be enabled (config loaded)
+ await waitFor(
+ () => {
+ const btn = canvas.getByRole('button', { name: /连接钱包/i })
+ expect(btn).toBeEnabled()
+ },
+ { 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)
+
+ // Wait for connect button to be enabled (config loaded)
+ await waitFor(
+ () => {
+ const btn = canvas.getByRole('button', { name: /连接钱包/i })
+ expect(btn).toBeEnabled()
+ },
+ { 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)
+
+ // Wait for connect button to be enabled (config loaded)
+ await waitFor(
+ () => {
+ const btn = canvas.getByRole('button', { name: /连接钱包/i })
+ expect(btn).toBeEnabled()
+ },
+ { 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 to be enabled (config loaded)
+ await waitFor(
+ () => {
+ const btn = canvas.getByRole('button', { name: /连接钱包/i })
+ expect(btn).toBeEnabled()
+ },
+ { 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 to be enabled (config loaded)
+ await waitFor(
+ () => {
+ const btn = canvas.getByRole('button', { name: /连接钱包/i })
+ expect(btn).toBeEnabled()
+ },
+ { timeout: 5000 }
+ )
+
+ // Click connect - should show error (Bio SDK not initialized)
+ await userEvent.click(canvas.getByRole('button', { name: /连接钱包/i }))
+
+ await waitFor(() => {
+ expect(canvas.getByText(/Bio SDK 未初始化/i)).toBeInTheDocument()
+ })
+ },
+}
diff --git a/miniapps/forge/src/App.test.tsx b/miniapps/forge/src/App.test.tsx
index 9b383c408..9a49be4a9 100644
--- a/miniapps/forge/src/App.test.tsx
+++ b/miniapps/forge/src/App.test.tsx
@@ -2,6 +2,35 @@ 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: {
+ 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 +45,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 +62,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 +78,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 +94,10 @@ describe('Forge App', () => {
render()
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument()
+ })
+
fireEvent.click(screen.getByRole('button', { name: '连接钱包' }))
await waitFor(() => {
@@ -65,6 +110,10 @@ describe('Forge App', () => {
render()
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: '连接钱包' })).toBeInTheDocument()
+ })
+
fireEvent.click(screen.getByRole('button', { name: '连接钱包' }))
await waitFor(() => {
@@ -73,17 +122,23 @@ describe('Forge App', () => {
})
it('should call bio_selectAccount on connect', async () => {
- mockBio.request.mockResolvedValue({ address: '0x123', chain: 'ethereum' })
+ 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: [{}],
- })
+ // 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 8ee80a358..380970373 100644
--- a/miniapps/forge/src/App.tsx
+++ b/miniapps/forge/src/App.tsx
@@ -1,7 +1,8 @@
-import { useState, useCallback, useEffect } from 'react'
-import type { BioAccount, BioUnsignedTransaction, BioSignedTransaction } from '@biochain/bio-sdk'
+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, 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 +11,66 @@ 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'
-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',
}
export default function App() {
+ const { t } = useTranslation()
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()
- // 关闭启动屏
+ // 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' })
}, [])
+ // 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 +80,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: [{ chain: selectedOption?.externalChain?.toLowerCase() }],
+ })
+ setExternalAccount(extAcc)
+
+ // Select internal chain account (for receiving)
+ const intAcc = await window.bio.request({
method: 'bio_selectAccount',
- params: [{}],
+ params: [{ chain: selectedOption?.internalChain }],
})
- setAccount(acc)
- setFromToken({ ...fromToken, balance: '2.5' })
+ 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 +112,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 (
@@ -171,7 +166,7 @@ export default function App() {
)}
- 锻造
+ {t('app.title')}
@@ -179,6 +174,23 @@ export default function App() {
{/* Content */}
+ {/* Loading config */}
+ {configLoading && (
+
+
+
+ )}
+
+ {/* Config error */}
+ {configError && (
+
+
+
+ {configError}
+
+
+ )}
+
{error && (
{/* Connect */}
- {step === 'connect' && (
+ {step === 'connect' && !configLoading && (
-
多链熔炉
-
将其他链资产锻造为 BFM 代币
+
{t('app.subtitle')}
+
{t('app.description')}
+
+ {/* Available chains preview */}
+ {forgeOptions.length > 0 && (
+
+ {Object.keys(groupedOptions).map((chain) => (
+
+ {getChainName(chain)}
+
+ ))}
+
+ )}
)}
{/* Swap */}
- {step === 'swap' && (
+ {step === 'swap' && selectedOption && (
- {/* From Card */}
+ {/* From Card (External Chain) */}
- 支付
- 余额: {fromToken.balance || '0.00'}
+ {t('forge.pay')} ({getChainName(selectedOption.externalChain)})
+
+ {externalAccount?.address?.slice(0, 8)}...
+
@@ -250,85 +275,69 @@ export default function App() {
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) : '--'}
+ {t('forge.receive')} ({getChainName(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}
+
+
+ {t('forge.ratio')}
+ 1:1
+
+
+ {t('forge.depositAddress')}
+
+ {selectedOption.externalInfo.depositAddress.slice(0, 10)}...
+
+
)}
@@ -337,16 +346,16 @@ export default function App() {
)}
{/* Confirm */}
- {step === 'confirm' && (
+ {step === 'confirm' && selectedOption && (
-
支付
+
+ {t('forge.pay')} ({getChainName(selectedOption.externalChain)})
+
-
- {fromAmount} {fromToken.symbol}
+
+ {amount} {selectedOption.externalAsset}
@@ -371,10 +382,12 @@ export default function App() {
-
获得
+
+ {t('forge.receive')} ({getChainName(selectedOption.internalChain)})
+
-
- {toAmount} {toToken.symbol}
+
+ {amount} {selectedOption.internalAsset}
@@ -383,22 +396,24 @@ export default function App() {
- 汇率
- 1 {fromToken.symbol} = {EXCHANGE_RATES[`${fromToken.symbol}-${toToken.symbol}`]} {toToken.symbol}
+ {t('forge.ratio')}
+ 1:1
-
网络
+
{t('forge.network')}
- {fromToken.chain}
+ {getChainName(selectedOption.externalChain)}
→
- {toToken.chain}
+ {getChainName(selectedOption.internalChain)}
- 预计时间
- ~30s
+ {t('forge.depositAddress')}
+
+ {selectedOption.externalInfo.depositAddress.slice(0, 10)}...
+
@@ -409,7 +424,7 @@ export default function App() {
onClick={handleConfirm}
disabled={loading}
>
- 确认锻造
+ {t('forge.confirm')}
@@ -428,14 +443,19 @@ export default function App() {
-
锻造中...
-
请稍候,正在处理交易
+
+ {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')}
)}
{/* Success */}
- {step === 'success' && (
+ {step === 'success' && selectedOption && (
-
锻造完成
-
{toAmount} {toToken.symbol}
+
{t('success.title')}
+
+ {amount} {selectedOption.internalAsset}
+
+ {forgeHook.orderId && (
+
+ {t('success.orderId')}: {forgeHook.orderId.slice(0, 16)}...
+
+ )}
)}
{/* Token Picker Modal */}
- {pickerOpen !== null && (
+ {pickerOpen && (
setPickerOpen(null)}
+ onClick={() => setPickerOpen(false)}
/>
- 选择代币
-
-
- {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]) => (
+
+
+ {getChainName(chain)}
+
+
+ {options.map((option) => (
+
handleSelectOption(option)}
+ >
+
+
+
+
+ {option.externalAsset} → {option.internalAsset}
+
+
+ {getChainName(option.externalChain)} → {getChainName(option.internalChain)}
+
+
+ {selectedOption?.externalAsset === option.externalAsset &&
+ selectedOption?.externalChain === option.externalChain && (
+ {t('picker.selected')}
+ )}
+
+
+ ))}
+
+
))}
@@ -505,16 +548,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 000000000..af407463f
--- /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 000000000..1cafd7e14
--- /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.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.test.ts b/miniapps/forge/src/api/helpers.test.ts
new file mode 100644
index 000000000..43752c192
--- /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/api/helpers.ts b/miniapps/forge/src/api/helpers.ts
new file mode 100644
index 000000000..aff2f410b
--- /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 000000000..8a924f58f
--- /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 000000000..539baf427
--- /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 000000000..e7238ebca
--- /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/components/BackgroundBeams.tsx b/miniapps/forge/src/components/BackgroundBeams.tsx
index b1e51b108..473a7f95f 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/hooks/index.ts b/miniapps/forge/src/hooks/index.ts
new file mode 100644
index 000000000..25a5f32a0
--- /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 000000000..a9ab31f70
--- /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.test.ts b/miniapps/forge/src/hooks/useForge.test.ts
new file mode 100644
index 000000000..9aac03ff6
--- /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/useForge.ts b/miniapps/forge/src/hooks/useForge.ts
new file mode 100644
index 000000000..f028d6f42
--- /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.test.ts b/miniapps/forge/src/hooks/useRechargeConfig.test.ts
new file mode 100644
index 000000000..515a70a91
--- /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')
+ })
+})
diff --git a/miniapps/forge/src/hooks/useRechargeConfig.ts b/miniapps/forge/src/hooks/useRechargeConfig.ts
new file mode 100644
index 000000000..0015b0bf4
--- /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 000000000..ed28f5b16
--- /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,
+ }
+}
diff --git a/miniapps/forge/src/i18n/index.test.ts b/miniapps/forge/src/i18n/index.test.ts
index f4b2eb71e..6ed305820 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 8fb46aa05..02ef96141 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 1c60e36ff..932d8dc50 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 f2b7a1455..c35137643 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 1c60e36ff..932d8dc50 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 000000000..4e934f5dc
--- /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
+}