diff --git a/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-address-page.png b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-address-page.png new file mode 100644 index 000000000..558ec23dd Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-address-page.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-error-balance.png b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-error-balance.png new file mode 100644 index 000000000..8f4c333fd Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-error-balance.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-signature-transfer.png b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-signature-transfer.png new file mode 100644 index 000000000..b6bc54a8a Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-signature-transfer.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-wallet-lock-confirm.png b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-wallet-lock-confirm.png new file mode 100644 index 000000000..00a4f5900 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/authorize.mock.spec.ts/authorize-wallet-lock-confirm.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png index 5f8ce3627..113c79c67 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02c-ecosystem-context-menu.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png index b70dcee52..3aac61901 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02d-context-menu-open.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png index d05aaebcd..e0f357280 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/02e-context-menu-detail.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png index f0a89b9c8..71d40f7d8 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png and b/e2e/__screenshots__/Mobile-Chrome/ecosystem-miniapp.mock.spec.ts/16-miniapp-detail.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/history-empty.png b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/history-empty.png index 1518dfa1b..473c4f8d9 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/history-empty.png and b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/history-empty.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-chain-selector.png b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-chain-selector.png index 057904718..9c4b40f5f 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-chain-selector.png and b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-chain-selector.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-with-wallet.png b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-with-wallet.png index 23a038d83..a28c8c0b7 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-with-wallet.png and b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/home-with-wallet.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-empty.png b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-empty.png index 353ef7da1..6ca4dca86 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-empty.png and b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-empty.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-filled.png b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-filled.png index e3813719f..40bd16f15 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-filled.png and b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/send-filled.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/wallet-detail.png b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/wallet-detail.png index 58a78f9de..92795589f 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/wallet-detail.png and b/e2e/__screenshots__/Mobile-Chrome/pages.mock.spec.ts/wallet-detail.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-filled.png b/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-filled.png index c60cf9c49..e69d021b6 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-filled.png and b/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-filled.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-initial.png b/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-initial.png index 353ef7da1..6ca4dca86 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-initial.png and b/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-initial.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-insufficient.png b/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-insufficient.png index ed44c2bd4..ac33401ef 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-insufficient.png and b/e2e/__screenshots__/Mobile-Chrome/send-transaction.mock.spec.ts/send-page-insufficient.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/01-home-empty.png b/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/01-home-empty.png index 0bcd73e77..dea04efa5 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/01-home-empty.png and b/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/01-home-empty.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/08-theme-step.png b/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/08-theme-step.png new file mode 100644 index 000000000..be3a7596b Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/08-theme-step.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/recover-01-key-type.png b/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/recover-01-key-type.png index 6abc59b97..b691b5898 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/recover-01-key-type.png and b/e2e/__screenshots__/Mobile-Chrome/wallet-create.spec.ts/recover-01-key-type.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/wallet-home-3d.spec.ts/content-tabs.png b/e2e/__screenshots__/Mobile-Chrome/wallet-home-3d.spec.ts/content-tabs.png new file mode 100644 index 000000000..470fd8cb0 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/wallet-home-3d.spec.ts/content-tabs.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/wallet-home-3d.spec.ts/wallet-card-3d.png b/e2e/__screenshots__/Mobile-Chrome/wallet-home-3d.spec.ts/wallet-card-3d.png new file mode 100644 index 000000000..15de9d7b2 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/wallet-home-3d.spec.ts/wallet-card-3d.png differ diff --git a/e2e/authorize.mock.spec.ts b/e2e/authorize.mock.spec.ts index fd0bbf240..51e2919ff 100644 --- a/e2e/authorize.mock.spec.ts +++ b/e2e/authorize.mock.spec.ts @@ -112,7 +112,7 @@ test.describe('DWEB 授权 - 截图测试', () => { await page.waitForLoadState('networkidle') // 点击确认按钮(使用多语言正则) - await page.locator(`button:has-text("${UI_TEXT.drawPattern.source}")`).click() + await page.getByRole('button', { name: UI_TEXT.drawPattern }).click() await page.waitForTimeout(500) await expect(page).toHaveScreenshot('authorize-wallet-lock-confirm.png') diff --git a/e2e/chain-config-subscription.spec.ts b/e2e/chain-config-subscription.spec.ts index a2c351574..8921a3db1 100644 --- a/e2e/chain-config-subscription.spec.ts +++ b/e2e/chain-config-subscription.spec.ts @@ -17,7 +17,7 @@ const SUBSCRIPTION_CONFIGS = [ { id: 'bf-sub', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BF Sub', symbol: 'BFS', decimals: 8, diff --git a/e2e/contact-scanner.mock.spec.ts b/e2e/contact-scanner.mock.spec.ts index 085b873de..f705a4736 100644 --- a/e2e/contact-scanner.mock.spec.ts +++ b/e2e/contact-scanner.mock.spec.ts @@ -53,6 +53,10 @@ async function createTestWallet(page: Page) { await page.waitForSelector('[data-testid="pattern-step"]') await drawPattern(page, 'pattern-lock-set-grid', DEFAULT_PATTERN) + const nextButton = page.locator('[data-testid="pattern-lock-next-button"]') + if (await nextButton.isVisible().catch(() => false)) { + await nextButton.click() + } await page.waitForSelector('[data-testid="pattern-lock-confirm-grid"]') await drawPattern(page, 'pattern-lock-confirm-grid', DEFAULT_PATTERN) @@ -74,7 +78,11 @@ async function createTestWallet(page: Page) { await page.click('[data-testid="verify-next-button"]') await page.waitForSelector('[data-testid="chain-selector-step"]') await page.click('[data-testid="chain-selector-complete-button"]') - await page.waitForURL('/#/') + + await page.waitForSelector('[data-testid="theme-step"]') + await page.click('[data-testid="theme-complete-button"]') + + await page.waitForURL(/.*#\/$/) await page.waitForSelector('[data-testid="chain-selector"]', { timeout: 10000 }) } @@ -106,27 +114,19 @@ test.describe('联系人分享流程', () => { }) test('通讯录页面可访问', async ({ page }) => { - await page.goto('/#/address-book') - await page.waitForTimeout(500) - - // 应该显示通讯录页面 - await expect(page.locator('text=通讯录').or(page.locator('text=Address Book'))).toBeVisible() + await page.getByTestId('tab-settings').click() + await page.getByTestId('address-book-button').click() + + const title = page.locator('[data-testid="page-title"]').filter({ hasText: /通讯录|Address Book/i }).first() + await expect(title).toBeVisible() }) test('可以添加联系人', async ({ page }) => { - await page.goto('/#/address-book') - await page.waitForTimeout(500) - - // 点击添加按钮 - const addButton = page.locator('[aria-label*="add" i], [aria-label*="添加" i], button:has(svg)').first() - if (await addButton.isVisible()) { - await addButton.click() - await page.waitForTimeout(300) - - // 应该打开添加联系人表单 - const nameInput = page.locator('input[placeholder*="name" i], input[placeholder*="名称" i]').first() - await expect(nameInput).toBeVisible({ timeout: 2000 }) - } + await page.getByTestId('tab-settings').click() + await page.getByTestId('address-book-button').click() + + await page.getByTestId('address-book-add-button').click() + await expect(page.getByRole('heading', { name: /添加联系人|Add Contact/i })).toBeVisible() }) }) diff --git a/e2e/helpers/i18n.ts b/e2e/helpers/i18n.ts index cd7180aa8..4400e4cf2 100644 --- a/e2e/helpers/i18n.ts +++ b/e2e/helpers/i18n.ts @@ -76,14 +76,14 @@ export function getAriaLabel(key: keyof typeof UI_TEXT): RegExp { * await btn.click() */ export function i18nLocator(page: Page, selector: string, text: RegExp): Locator { - return page.locator(`${selector}:has-text("${text.source}")`) + return page.locator(selector).filter({ hasText: text }) } /** * 等待多语言文本出现 */ export async function waitForI18nText(page: Page, text: RegExp, options?: { timeout?: number }) { - await page.waitForSelector(`text=${text.source}`, options) + await page.getByText(text).first().waitFor({ state: 'visible', timeout: options?.timeout }) } /** diff --git a/e2e/pages.mock.spec.ts b/e2e/pages.mock.spec.ts index c105aeb64..ed5d2f0b3 100644 --- a/e2e/pages.mock.spec.ts +++ b/e2e/pages.mock.spec.ts @@ -256,12 +256,13 @@ test.describe('通知页面', () => { // 测试地址簿数据(多地址联系人) const TEST_CONTACTS_DATA = { + version: 3, contacts: [ { id: 'contact-1', name: 'Alice', addresses: [ - { id: 'addr-1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum', isDefault: true }, + { id: 'addr-1', address: '0x1234567890abcdef1234567890abcdef12345678', isDefault: true }, ], memo: '同事', createdAt: Date.now() - 86400000, @@ -271,8 +272,8 @@ const TEST_CONTACTS_DATA = { id: 'contact-2', name: 'Bob', addresses: [ - { id: 'addr-2', address: '0xabcdef1234567890abcdef1234567890abcdef12', chainType: 'ethereum', isDefault: true }, - { id: 'addr-3', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', chainType: 'bfmeta' }, + { id: 'addr-2', address: '0xabcdef1234567890abcdef1234567890abcdef12', isDefault: true }, + { id: 'addr-3', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3' }, ], createdAt: Date.now() - 172800000, updatedAt: Date.now() - 172800000, @@ -281,17 +282,15 @@ const TEST_CONTACTS_DATA = { id: 'contact-3', name: '多链用户', addresses: [ - { id: 'addr-4', address: '0x9876543210fedcba9876543210fedcba98765432', chainType: 'ethereum', isDefault: true }, - { id: 'addr-5', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', chainType: 'bfmeta' }, - { id: 'addr-6', address: 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW', chainType: 'tron' }, + { id: 'addr-4', address: '0x9876543210fedcba9876543210fedcba98765432', isDefault: true }, + { id: 'addr-5', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3' }, + { id: 'addr-6', address: 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW' }, ], memo: '支持多链转账', createdAt: Date.now() - 259200000, updatedAt: Date.now() - 259200000, }, ], - isInitialized: true, - version: 2, } // 辅助函数:设置测试联系人 diff --git a/e2e/screenshots/contact-01-address-book.png b/e2e/screenshots/contact-01-address-book.png index a71b4a8ff..ff040bc6f 100644 Binary files a/e2e/screenshots/contact-01-address-book.png and b/e2e/screenshots/contact-01-address-book.png differ diff --git a/e2e/screenshots/contact-02-send-page.png b/e2e/screenshots/contact-02-send-page.png index a71b4a8ff..6f5c794a4 100644 Binary files a/e2e/screenshots/contact-02-send-page.png and b/e2e/screenshots/contact-02-send-page.png differ diff --git a/e2e/screenshots/contact-03-address-input-focused.png b/e2e/screenshots/contact-03-address-input-focused.png index 1c19c7bd3..d841758e3 100644 Binary files a/e2e/screenshots/contact-03-address-input-focused.png and b/e2e/screenshots/contact-03-address-input-focused.png differ diff --git a/e2e/screenshots/debug-01-address-book.png b/e2e/screenshots/debug-01-address-book.png index a71b4a8ff..532134018 100644 Binary files a/e2e/screenshots/debug-01-address-book.png and b/e2e/screenshots/debug-01-address-book.png differ diff --git a/e2e/screenshots/debug-02-send-page.png b/e2e/screenshots/debug-02-send-page.png index a71b4a8ff..532134018 100644 Binary files a/e2e/screenshots/debug-02-send-page.png and b/e2e/screenshots/debug-02-send-page.png differ diff --git a/e2e/wallet-create.spec.ts b/e2e/wallet-create.spec.ts index 78527d270..b8b37273d 100644 --- a/e2e/wallet-create.spec.ts +++ b/e2e/wallet-create.spec.ts @@ -8,6 +8,7 @@ import { getWalletDataFromIndexedDB } from './utils/indexeddb-helper' */ const DEFAULT_PATTERN = [0, 1, 2, 5] +const E2E_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' async function drawPattern(page: Page, gridTestId: string, nodes: number[]): Promise { const grid = page.locator(`[data-testid="${gridTestId}"]`) @@ -53,6 +54,9 @@ async function fillVerifyInputs(page: Page, words: string[]): Promise { test.describe('钱包创建流程 - 截图测试', () => { test.beforeEach(async ({ page }) => { // 清除本地存储,确保干净状态 + await page.addInitScript((mnemonic) => { + ;(window as any).__E2E_MNEMONIC__ = mnemonic + }, E2E_MNEMONIC) await page.addInitScript(() => localStorage.clear()) }) @@ -132,6 +136,9 @@ test.describe('钱包创建流程 - 截图测试', () => { test.describe('钱包创建流程 - 功能测试', () => { test.beforeEach(async ({ page }) => { + await page.addInitScript((mnemonic) => { + ;(window as any).__E2E_MNEMONIC__ = mnemonic + }, E2E_MNEMONIC) await page.addInitScript(() => localStorage.clear()) }) @@ -190,14 +197,20 @@ test.describe('钱包创建流程 - 功能测试', () => { // 7. 验证跳转到首页且钱包已创建 await page.waitForURL(/.*#\/$/) - // 等待钱包名称显示,确认首页加载完成 - await expect(page.locator('[data-testid="wallet-name"]:visible').first()).toBeVisible({ timeout: 10000 }) + + await expect + .poll(async () => { + const wallets = await getWalletDataFromIndexedDB(page) + return wallets.length + }, { + timeout: 10_000, + }) + .toBe(1) const wallets = await getWalletDataFromIndexedDB(page) - expect(wallets).toHaveLength(1) - expect(wallets[0].name).toBe('主钱包') + expect(wallets[0]?.name).toBe('主钱包') - const bioforestChains = ['bfmeta', 'pmchain', 'ccchain', 'bfchainv2', 'btgmeta', 'biwmeta', 'ethmeta', 'malibu'] + const bioforestChains = ['bfmeta', 'pmchain', 'ccchain', 'bfchainv2', 'btgmeta', 'biwmeta', 'ethmeta'] for (const chain of bioforestChains) { const chainAddr = wallets[0].chainAddresses.find((ca: { chain: string }) => ca.chain === chain) expect(chainAddr, `应该有 ${chain} 地址`).toBeDefined() @@ -211,7 +224,7 @@ test.describe('钱包创建流程 - 功能测试', () => { const manualConfig = JSON.stringify({ id: 'bf-demo', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BF Demo', symbol: 'BFD', decimals: 8, @@ -253,8 +266,9 @@ test.describe('钱包创建流程 - 功能测试', () => { await page.waitForSelector('[data-testid="chain-selector-step"]') await page.locator('[data-testid="chain-selector-group-toggle-evm"]').click() await page.locator('[data-testid="chain-selector-chain-ethereum"]').click() - await page.locator('[data-testid="chain-selector-group-toggle-bip39"]').click() + await page.locator('[data-testid="chain-selector-group-toggle-bitcoin"]').click() await page.locator('[data-testid="chain-selector-chain-bitcoin"]').click() + await page.locator('[data-testid="chain-selector-group-toggle-tron"]').click() await page.locator('[data-testid="chain-selector-chain-tron"]').click() await page.click('[data-testid="chain-selector-complete-button"]') @@ -324,6 +338,9 @@ test.describe('钱包创建流程 - 功能测试', () => { test.describe('钱包导入流程 - 截图测试', () => { test.beforeEach(async ({ page }) => { + await page.addInitScript((mnemonic) => { + ;(window as any).__E2E_MNEMONIC__ = mnemonic + }, E2E_MNEMONIC) await page.addInitScript(() => localStorage.clear()) }) diff --git a/e2e/wallet-home-3d.spec.ts b/e2e/wallet-home-3d.spec.ts index a4aa2f8aa..ed1a97ef2 100644 --- a/e2e/wallet-home-3d.spec.ts +++ b/e2e/wallet-home-3d.spec.ts @@ -11,53 +11,98 @@ import { test, expect, type Page } from '@playwright/test' * - 钱包列表展开 */ +const E2E_WALLET_SEED = { + wallets: [ + { + id: 'e2e-wallet-ethereum', + name: 'E2E Wallet', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f00000', + chain: 'ethereum', + keyType: 'mnemonic', + encryptedMnemonic: { + ciphertext: 'e2e', + iv: 'e2e', + salt: 'e2e', + }, + createdAt: 1, + chainAddresses: [ + { + chain: 'ethereum', + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f00000', + tokens: [], + }, + ], + }, + ], + currentWalletId: 'e2e-wallet-ethereum', +} + +async function seedWallet(page: Page) { + await page.addInitScript(async (seed) => { + localStorage.clear() + sessionStorage.clear() + + const databases = await indexedDB.databases() + for (const db of databases) { + if (db.name) indexedDB.deleteDatabase(db.name) + } + + localStorage.setItem('bfm_wallets', JSON.stringify(seed)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: 'zh-CN', currency: 'CNY' })) + }, E2E_WALLET_SEED) +} + test.describe('Wallet Home 3D', () => { test.beforeEach(async ({ page }) => { - // 假设已有钱包,直接访问首页 + await seedWallet(page) await page.goto('/') + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('wallet-card-container')).toBeVisible({ timeout: 10_000 }) }) test.describe('Wallet Card Display', () => { test('should display wallet card with 3D perspective', async ({ page }) => { - const card = page.locator('.wallet-card-container') + const card = page.getByTestId('wallet-card-container') await expect(card).toBeVisible() // 检查 3D 透视容器 - const perspectiveContainer = page.locator('.perspective-\\[1000px\\]') - await expect(perspectiveContainer).toBeVisible() + await expect(card).toHaveCSS('perspective', '1000px') }) test('should display wallet name on card', async ({ page }) => { - const walletName = page.locator('.wallet-card h2') + const walletName = page.getByTestId('wallet-name') await expect(walletName).toBeVisible() }) test('should display chain selector button', async ({ page }) => { - const chainButton = page.locator('.wallet-card button').filter({ hasText: /Ethereum|Tron|Bitcoin/i }) - await expect(chainButton.first()).toBeVisible() + await expect(page.getByTestId('wallet-card').getByTestId('chain-selector')).toBeVisible() }) test('should display truncated address', async ({ page }) => { - const address = page.locator('.wallet-card .font-mono') - await expect(address).toBeVisible() - const text = await address.textContent() - expect(text).toMatch(/^0x[\da-f]{4,6}\.{3}[\da-f]{4}$/i) + const address = E2E_WALLET_SEED.wallets[0].address + const last4 = address.slice(-4) + + const addressText = page.getByTestId('wallet-card-address-text') + await expect(page.getByTestId('wallet-card-address')).toHaveAttribute('title', address) + await expect(addressText).toBeVisible() + + const text = (await addressText.textContent()) ?? '' + expect(text).toContain('...') + expect(text).toMatch(/^0x/i) + expect(text.toLowerCase()).toContain(last4.toLowerCase()) }) }) test.describe('Wallet Card Interactions', () => { test('should copy address on click', async ({ page }) => { - // 找到复制按钮(最后一个按钮在底部行) - const copyButton = page.locator('.wallet-card button').last() + const copyButton = page.getByTestId('wallet-card-copy-button') await copyButton.click() - // 应该显示成功状态(绿色勾) - const checkIcon = page.locator('.wallet-card .text-green-300') - await expect(checkIcon).toBeVisible({ timeout: 1000 }) + await expect(copyButton).toHaveAttribute('data-copied', 'true', { timeout: 1000 }) }) test('should open chain selector on chain button click', async ({ page }) => { - const chainButton = page.locator('.wallet-card button').filter({ hasText: /Ethereum|Tron|Bitcoin/i }).first() + const chainButton = page.getByTestId('wallet-card').getByTestId('chain-selector') await chainButton.click() // 应该打开链选择器 @@ -66,7 +111,7 @@ test.describe('Wallet Home 3D', () => { }) test('should respond to mouse hover with 3D effect', async ({ page }) => { - const card = page.locator('.wallet-card') + const card = page.getByTestId('wallet-card') const box = await card.boundingBox() if (!box) throw new Error('Card not visible') @@ -115,29 +160,26 @@ test.describe('Wallet Home 3D', () => { test.describe('Quick Actions', () => { test('should display send button', async ({ page }) => { - const sendButton = page.locator('button').filter({ hasText: /发送|转账|Send/i }) - await expect(sendButton.first()).toBeVisible() + await expect(page.getByTestId('wallet-home-send-button')).toBeVisible() }) test('should display receive button', async ({ page }) => { - const receiveButton = page.locator('button').filter({ hasText: /收款|Receive/i }) - await expect(receiveButton.first()).toBeVisible() + await expect(page.getByTestId('wallet-home-receive-button')).toBeVisible() }) test('should display scan button', async ({ page }) => { - const scanButton = page.locator('button').filter({ hasText: /扫码|Scan/i }) - await expect(scanButton.first()).toBeVisible() + await expect(page.getByTestId('wallet-home-scan-button')).toBeVisible() }) test('should navigate to send page', async ({ page }) => { - const sendButton = page.locator('button').filter({ hasText: /发送|转账|Send/i }).first() + const sendButton = page.getByTestId('wallet-home-send-button') await sendButton.click() await expect(page).toHaveURL(/\/send/) }) test('should navigate to receive page', async ({ page }) => { - const receiveButton = page.locator('button').filter({ hasText: /收款|Receive/i }).first() + const receiveButton = page.getByTestId('wallet-home-receive-button') await receiveButton.click() await expect(page).toHaveURL(/\/receive/) @@ -146,55 +188,30 @@ test.describe('Wallet Home 3D', () => { test.describe('Content Tabs', () => { test('should display assets and history tabs', async ({ page }) => { - const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i }) - const historyTab = page.locator('button').filter({ hasText: /交易|History/i }) - - await expect(assetsTab.first()).toBeVisible() - await expect(historyTab.first()).toBeVisible() + await expect(page.getByTestId('wallet-home-content-tabs-tab-assets')).toBeVisible() + await expect(page.getByTestId('wallet-home-content-tabs-tab-history')).toBeVisible() }) test('should show assets content by default', async ({ page }) => { - // 资产 tab 应该默认激活 - const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i }).first() - await expect(assetsTab).toHaveClass(/text-primary|border-primary/) + await expect(page.getByTestId('wallet-home-content-tabs-tab-assets')).toHaveAttribute('data-active', 'true') }) test('should switch to history tab', async ({ page }) => { - const historyTab = page.locator('button').filter({ hasText: /交易|History/i }).first() + const historyTab = page.getByTestId('wallet-home-content-tabs-tab-history') await historyTab.click() - - await expect(historyTab).toHaveClass(/text-primary|border-primary|text-foreground/) + await expect(historyTab).toHaveAttribute('data-active', 'true') }) test('should display token list in assets tab', async ({ page }) => { - // 确保在资产 tab - const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i }).first() - await assetsTab.click() - - // 应该显示代币列表或空状态 - const tokenList = page.locator('[class*="token"]') - const emptyState = page.locator('[class*="empty"]') - - const hasTokens = await tokenList.first().isVisible().catch(() => false) - const isEmpty = await emptyState.first().isVisible().catch(() => false) - - expect(hasTokens || isEmpty).toBeTruthy() + await page.getByTestId('wallet-home-content-tabs-tab-assets').click() + const tokenListOrEmpty = page.locator('[data-testid="token-list"], [data-testid="token-list-empty"]').first() + await expect(tokenListOrEmpty).toBeVisible() }) test('should display transaction list in history tab', async ({ page }) => { - const historyTab = page.locator('button').filter({ hasText: /交易|History/i }).first() - await historyTab.click() - - await page.waitForTimeout(300) - - // 应该显示交易列表或空状态 - const txList = page.locator('[class*="transaction"]') - const emptyState = page.locator('[class*="empty"]') - - const hasTx = await txList.first().isVisible().catch(() => false) - const isEmpty = await emptyState.first().isVisible().catch(() => false) - - expect(hasTx || isEmpty).toBeTruthy() + await page.getByTestId('wallet-home-content-tabs-tab-history').click() + const txListOrEmpty = page.locator('[data-testid="transaction-list"], [data-testid="transaction-list-empty"]').first() + await expect(txListOrEmpty).toBeVisible() }) }) @@ -257,30 +274,26 @@ test.describe('Wallet Home 3D', () => { }) test.describe('Tab Bar', () => { - test('should display only 2 tabs (wallet and settings)', async ({ page }) => { - const tabBar = page.locator('[class*="tab-bar"], nav') - if (!(await tabBar.isVisible())) return + test('should display wallet/ecosystem/settings tabs', async ({ page }) => { + const tabBar = page.getByTestId('tab-bar') + await expect(tabBar).toBeVisible() - const tabs = tabBar.locator('button, a') - const count = await tabs.count() - - // 应该只有 2 个 tab - expect(count).toBe(2) + await expect(page.getByTestId('tab-wallet')).toBeVisible() + await expect(page.getByTestId('tab-ecosystem')).toBeVisible() + await expect(page.getByTestId('tab-settings')).toBeVisible() }) test('should navigate to settings', async ({ page }) => { - const settingsTab = page.locator('button, a').filter({ hasText: /设置|Settings/i }).first() - if (!(await settingsTab.isVisible())) return - + const settingsTab = page.getByTestId('tab-settings') await settingsTab.click() - await expect(page).toHaveURL(/\/settings/) + await expect(settingsTab).toHaveAttribute('aria-current', 'page') }) }) test.describe('Visual Regression', () => { test('wallet card should match snapshot', async ({ page }) => { - const card = page.locator('.wallet-card-container') + const card = page.getByTestId('wallet-card-container') if (!(await card.isVisible())) return await expect(card).toHaveScreenshot('wallet-card-3d.png', { @@ -289,7 +302,7 @@ test.describe('Wallet Home 3D', () => { }) test('content tabs should match snapshot', async ({ page }) => { - const tabs = page.locator('[class*="content-tabs"], [class*="ContentTabs"]').first() + const tabs = page.getByTestId('wallet-home-content-tabs') if (!(await tabs.isVisible())) return await expect(tabs).toHaveScreenshot('content-tabs.png', { @@ -344,7 +357,7 @@ test.describe('Wallet Home 3D - Accessibility', () => { }) test('wallet card should have proper aria labels', async ({ page }) => { - const card = page.locator('.wallet-card') + const card = page.getByTestId('wallet-card') if (!(await card.isVisible())) return // 按钮应该有 aria-label @@ -360,8 +373,8 @@ test.describe('Wallet Home 3D - Accessibility', () => { }) test('tabs should be keyboard navigable', async ({ page }) => { - const assetsTab = page.locator('button').filter({ hasText: /资产|Assets/i }).first() - const historyTab = page.locator('button').filter({ hasText: /交易|History/i }).first() + const assetsTab = page.getByTestId('wallet-home-content-tabs-tab-assets') + const historyTab = page.getByTestId('wallet-home-content-tabs-tab-history') if (!(await assetsTab.isVisible())) return diff --git a/e2e/wallet-recover-arbitrary.spec.ts b/e2e/wallet-recover-arbitrary.spec.ts index 518821a04..7a8fed2ae 100644 --- a/e2e/wallet-recover-arbitrary.spec.ts +++ b/e2e/wallet-recover-arbitrary.spec.ts @@ -14,7 +14,7 @@ import { getWalletDataFromIndexedDB } from './utils/indexeddb-helper' const MANUAL_CHAIN = { id: 'bf-arbitrary-e2e', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BF Arbitrary E2E', symbol: 'BFA', decimals: 8, diff --git a/public/configs/default-chains.json b/public/configs/default-chains.json index 3fb784744..a037c6dab 100644 --- a/public/configs/default-chains.json +++ b/public/configs/default-chains.json @@ -4,7 +4,7 @@ { "id": "bfmeta", "version": "1.0", - "type": "bioforest", + "chainKind": "bioforest", "name": "BFMeta", "symbol": "BFM", "icon": "../icons/bfmeta/chain.svg", @@ -28,7 +28,7 @@ { "id": "ccchain", "version": "1.0", - "type": "bioforest", + "chainKind": "bioforest", "name": "CCChain", "symbol": "CCC", "icon": "../icons/ccchain/chain.svg", @@ -46,7 +46,7 @@ { "id": "pmchain", "version": "1.0", - "type": "bioforest", + "chainKind": "bioforest", "name": "PMChain", "symbol": "PMC", "icon": "../icons/pmchain/chain.svg", @@ -64,7 +64,7 @@ { "id": "bfchainv2", "version": "1.0", - "type": "bioforest", + "chainKind": "bioforest", "name": "BFChain V2", "symbol": "BFT", "icon": "../icons/bfchainv2/chain.svg", @@ -82,7 +82,7 @@ { "id": "btgmeta", "version": "1.0", - "type": "bioforest", + "chainKind": "bioforest", "name": "BTGMeta", "symbol": "BTGM", "icon": "../icons/btgmeta/chain.svg", @@ -100,7 +100,7 @@ { "id": "biwmeta", "version": "1.0", - "type": "bioforest", + "chainKind": "bioforest", "name": "BIWMeta", "symbol": "BIW", "tokenIconBase": [ @@ -122,7 +122,7 @@ { "id": "ethmeta", "version": "1.0", - "type": "bioforest", + "chainKind": "bioforest", "name": "ETHMeta", "symbol": "ETHM", "icon": "../icons/ethmeta/chain.svg", @@ -140,7 +140,7 @@ { "id": "ethereum", "version": "1.0", - "type": "evm", + "chainKind": "evm", "name": "Ethereum", "symbol": "ETH", "icon": "../icons/ethereum/chain.svg", @@ -163,7 +163,7 @@ { "id": "binance", "version": "1.0", - "type": "evm", + "chainKind": "evm", "name": "BNB Smart Chain", "symbol": "BNB", "icon": "../icons/binance/chain.svg", @@ -186,7 +186,7 @@ { "id": "tron", "version": "1.0", - "type": "tron", + "chainKind": "tron", "name": "Tron", "symbol": "TRX", "icon": "../icons/tron/chain.svg", @@ -208,7 +208,7 @@ { "id": "bitcoin", "version": "1.0", - "type": "bip39", + "chainKind": "bitcoin", "name": "Bitcoin", "symbol": "BTC", "icon": "../icons/bitcoin/chain.svg", diff --git a/src/components/common/empty-state.tsx b/src/components/common/empty-state.tsx index 577470540..d26416064 100644 --- a/src/components/common/empty-state.tsx +++ b/src/components/common/empty-state.tsx @@ -6,11 +6,15 @@ interface EmptyStateProps { description?: string | undefined; action?: React.ReactNode | undefined; className?: string | undefined; + testId?: string | undefined; } -export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { +export function EmptyState({ icon, title, description, action, className, testId }: EmptyStateProps) { return ( -
+
{icon &&
{icon}
}

{title}

{description &&

{description}

} diff --git a/src/components/layout/swipeable-tabs.tsx b/src/components/layout/swipeable-tabs.tsx index e2158973e..a64b609a2 100644 --- a/src/components/layout/swipeable-tabs.tsx +++ b/src/components/layout/swipeable-tabs.tsx @@ -18,6 +18,7 @@ interface TabsProps { onTabChange?: (tabId: string) => void children: (activeTab: string) => ReactNode className?: string + testIdPrefix?: string } const DEFAULT_TABS: Tab[] = [ @@ -89,6 +90,7 @@ export function SwipeableTabs({ onTabChange, children, className, + testIdPrefix, }: TabsProps) { const [internalActiveTab, setInternalActiveTab] = useState(defaultTab) const swiperRef = useRef(null) @@ -130,7 +132,7 @@ export function SwipeableTabs({ ) return ( -
+
{/* 指示器 - 使用 CSS 变量实现实时位置更新 */} @@ -149,6 +151,8 @@ export function SwipeableTabs({ diff --git a/src/pages/address-transactions/index.test.tsx b/src/pages/address-transactions/index.test.tsx index 4a414cdea..6199770d3 100644 --- a/src/pages/address-transactions/index.test.tsx +++ b/src/pages/address-transactions/index.test.tsx @@ -36,7 +36,7 @@ const mockEnabledChains = [ { id: 'ethereum', version: '1.0', - type: 'evm', + chainKind: 'evm', name: 'Ethereum', symbol: 'ETH', decimals: 18, @@ -50,7 +50,7 @@ const mockEnabledChains = [ { id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFM', decimals: 8, diff --git a/src/pages/address-transactions/index.tsx b/src/pages/address-transactions/index.tsx index eb015adb6..9f19caad5 100644 --- a/src/pages/address-transactions/index.tsx +++ b/src/pages/address-transactions/index.tsx @@ -106,8 +106,8 @@ export function AddressTransactionsPage() { } }, [explorerUrl]) - const evmChains = enabledChains.filter((c) => c.type === 'evm') - const otherChains = enabledChains.filter((c) => c.type !== 'evm') + const evmChains = enabledChains.filter((c) => c.chainKind === 'evm') + const otherChains = enabledChains.filter((c) => c.chainKind !== 'evm') return (
diff --git a/src/pages/history/index.test.tsx b/src/pages/history/index.test.tsx index 22ed4aad7..ffde974ac 100644 --- a/src/pages/history/index.test.tsx +++ b/src/pages/history/index.test.tsx @@ -27,7 +27,7 @@ const mockEnabledChains = [ { id: 'ethereum', version: '1.0', - type: 'evm', + chainKind: 'evm', name: 'Ethereum', symbol: 'ETH', decimals: 18, @@ -37,7 +37,7 @@ const mockEnabledChains = [ { id: 'tron', version: '1.0', - type: 'bip39', + chainKind: 'bitcoin', name: 'Tron', symbol: 'TRX', decimals: 6, diff --git a/src/pages/onboarding/recover.tsx b/src/pages/onboarding/recover.tsx index 01aa18146..579f30af6 100644 --- a/src/pages/onboarding/recover.tsx +++ b/src/pages/onboarding/recover.tsx @@ -17,7 +17,8 @@ import { GradientButton } from '@/components/common/gradient-button'; import { IconCircle } from '@/components/common/icon-circle'; import { LoadingSpinner } from '@/components/common/loading-spinner'; import { useDuplicateDetection } from '@/hooks/use-duplicate-detection'; -import { deriveMultiChainKeys, deriveBioforestAddresses } from '@/lib/crypto'; +import { deriveBioforestAddresses } from '@/lib/crypto'; +import { buildWalletChainAddresses } from '@/services/wallet/chain-derivation'; import { deriveThemeHue } from '@/hooks/useWalletTheme'; import { useChainConfigs, useChainConfigState, useEnabledBioforestChainConfigs, walletActions } from '@/stores'; import type { IWalletQuery } from '@/services/wallet/types'; @@ -186,65 +187,18 @@ export function OnboardingRecoverPage() { try { const mnemonicStr = words.join(' '); - // 根据选中的链配置派生地址 - const selectedConfigs = chainConfigs.filter((config) => selectedChainIds.includes(config.id)); - const selectedBioforestConfigs = selectedConfigs.filter((config) => config.type === 'bioforest'); - const selectedBip39Ids = new Set( - selectedConfigs.filter((config) => config.type === 'bip39').map((config) => config.id), - ); - const selectedEvmConfigs = selectedConfigs.filter( - (config) => config.type === 'evm' || config.type === 'custom', - ); - - const externalChains: Array<'ethereum' | 'bitcoin' | 'tron'> = []; - if (selectedEvmConfigs.length > 0) externalChains.push('ethereum'); - if (selectedBip39Ids.has('bitcoin')) externalChains.push('bitcoin'); - if (selectedBip39Ids.has('tron')) externalChains.push('tron'); - - const externalKeys = externalChains.length > 0 - ? deriveMultiChainKeys(mnemonicStr, externalChains, 0) - : []; - - const addressByChain = new Map(); - const ethKey = externalKeys.find((k) => k.chain === 'ethereum'); - if (ethKey) { - if (selectedChainIds.includes('ethereum')) { - addressByChain.set('ethereum', ethKey.address); - } - for (const config of selectedEvmConfigs) { - addressByChain.set(config.id, ethKey.address); - } - } - - const bitcoinKey = externalKeys.find((k) => k.chain === 'bitcoin'); - if (bitcoinKey && selectedBip39Ids.has('bitcoin')) { - addressByChain.set('bitcoin', bitcoinKey.address); - } - - const tronKey = externalKeys.find((k) => k.chain === 'tron'); - if (tronKey && selectedBip39Ids.has('tron')) { - addressByChain.set('tron', tronKey.address); - } + // 使用统一的 chain-derivation 模块派生所有地址 + const derivedAddresses = buildWalletChainAddresses({ + mnemonic: mnemonicStr, + selectedChainIds, + chainConfigs, + }); - const bioforestChainAddresses = deriveBioforestAddresses( - mnemonicStr, - selectedBioforestConfigs.length > 0 ? selectedBioforestConfigs : [], - ); - for (const item of bioforestChainAddresses) { - addressByChain.set(item.chainId, item.address); - } - - const chainAddresses = selectedChainIds - .map((chainId) => { - const address = addressByChain.get(chainId); - if (!address) return null; - return { - chain: chainId, - address, - tokens: [], - }; - }) - .filter((item): item is { chain: string; address: string; tokens: [] } => Boolean(item)); + const chainAddresses = derivedAddresses.map(({ chainId, address }) => ({ + chain: chainId, + address, + tokens: [], + })); const primaryChain = chainAddresses[0]; if (!primaryChain) { diff --git a/src/pages/settings/chain-config.test.tsx b/src/pages/settings/chain-config.test.tsx index d20b42d4c..0fed11df7 100644 --- a/src/pages/settings/chain-config.test.tsx +++ b/src/pages/settings/chain-config.test.tsx @@ -19,7 +19,7 @@ const mockSetChainEnabled = vi.fn<(id: string, enabled: boolean) => Promise { { id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFT', decimals: 8, diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 5e7723e46..96b6ace91 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -203,6 +203,7 @@ export function SettingsPage() { icon={} label={t('settings:items.addressBook')} onClick={() => navigate({ to: '/address-book' })} + testId="address-book-button" /> diff --git a/src/pages/wallet/create.tsx b/src/pages/wallet/create.tsx index 89ae536d9..040e01502 100644 --- a/src/pages/wallet/create.tsx +++ b/src/pages/wallet/create.tsx @@ -22,7 +22,8 @@ import { IconCircleCheck as CheckCircle, } from '@tabler/icons-react'; import { useChainConfigs, walletActions } from '@/stores'; -import { generateMnemonic, deriveMultiChainKeys, deriveBioforestAddresses } from '@/lib/crypto'; +import { generateMnemonic } from '@/lib/crypto'; +import { buildWalletChainAddresses } from '@/services/wallet/chain-derivation'; import { deriveThemeHue } from '@/hooks/useWalletTheme'; import type { ChainConfig } from '@/services/chain-config'; @@ -36,7 +37,19 @@ export function WalletCreatePage() { const chainConfigs = useChainConfigs(); const [step, setStep] = useState('pattern'); const [patternKey, setPatternKey] = useState(''); - const [mnemonic] = useState(generateMnemonic); + const [mnemonic] = useState(() => { + if (typeof window !== 'undefined') { + const injected = (window as any).__E2E_MNEMONIC__; + if (typeof injected === 'string' && injected.trim()) { + return injected.trim().split(/\s+/); + } + if (Array.isArray(injected) && injected.every((w: unknown) => typeof w === 'string')) { + return injected as string[]; + } + } + + return generateMnemonic(); + }); const [mnemonicHidden, setMnemonicHidden] = useState(true); const [mnemonicCopied, setMnemonicCopied] = useState(false); const [selectedChainIds, setSelectedChainIds] = useState([]); @@ -90,64 +103,18 @@ export function WalletCreatePage() { try { const mnemonicStr = mnemonic.join(' '); - const selectedConfigs = chainConfigs.filter((config) => selectedChainIds.includes(config.id)); - const selectedBioforestConfigs = selectedConfigs.filter((config) => config.type === 'bioforest'); - const selectedBip39Ids = new Set( - selectedConfigs.filter((config) => config.type === 'bip39').map((config) => config.id), - ); - const selectedEvmConfigs = selectedConfigs.filter( - (config) => config.type === 'evm' || config.type === 'custom', - ); - - const externalChains: Array<'ethereum' | 'bitcoin' | 'tron'> = []; - if (selectedEvmConfigs.length > 0) externalChains.push('ethereum'); - if (selectedBip39Ids.has('bitcoin')) externalChains.push('bitcoin'); - if (selectedBip39Ids.has('tron')) externalChains.push('tron'); - - const externalKeys = externalChains.length > 0 - ? deriveMultiChainKeys(mnemonicStr, externalChains, 0) - : []; - - const addressByChain = new Map(); - const ethKey = externalKeys.find((k) => k.chain === 'ethereum'); - if (ethKey) { - if (selectedChainIds.includes('ethereum')) { - addressByChain.set('ethereum', ethKey.address); - } - for (const config of selectedEvmConfigs) { - addressByChain.set(config.id, ethKey.address); - } - } - - const bitcoinKey = externalKeys.find((k) => k.chain === 'bitcoin'); - if (bitcoinKey && selectedBip39Ids.has('bitcoin')) { - addressByChain.set('bitcoin', bitcoinKey.address); - } - - const tronKey = externalKeys.find((k) => k.chain === 'tron'); - if (tronKey && selectedBip39Ids.has('tron')) { - addressByChain.set('tron', tronKey.address); - } - - const bioforestChainAddresses = deriveBioforestAddresses( - mnemonicStr, - selectedBioforestConfigs.length > 0 ? selectedBioforestConfigs : [], - ); - for (const item of bioforestChainAddresses) { - addressByChain.set(item.chainId, item.address); - } - - const chainAddresses = selectedChainIds - .map((chainId) => { - const address = addressByChain.get(chainId); - if (!address) return null; - return { - chain: chainId, - address, - tokens: [], - }; - }) - .filter((item): item is { chain: string; address: string; tokens: [] } => Boolean(item)); + // 使用统一的 chain-derivation 模块派生所有地址 + const derivedAddresses = buildWalletChainAddresses({ + mnemonic: mnemonicStr, + selectedChainIds, + chainConfigs, + }); + + const chainAddresses = derivedAddresses.map(({ chainId, address }) => ({ + chain: chainId, + address, + tokens: [], + })); const primaryChain = chainAddresses[0]; if (!primaryChain) { diff --git a/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts b/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts index 3938fd793..9787637fc 100644 --- a/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts +++ b/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi, beforeEach } from 'vitest' import type { ChainConfig } from '@/services/chain-config' import { Amount } from '@/types/amount' import { createBioforestKeypair, publicKeyToBioforestAddress, verifySignature, hexToBytes } from '@/lib/crypto' @@ -11,7 +11,7 @@ const validAddress = publicKeyToBioforestAddress(testKeypair.publicKey, 'b') const mockBfmetaConfig: ChainConfig = { id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFM', prefix: 'b', @@ -20,6 +20,17 @@ const mockBfmetaConfig: ChainConfig = { source: 'default', } +// Mock chainConfigService +vi.mock('@/services/chain-config/service', () => ({ + chainConfigService: { + getConfig: (chainId: string) => chainId === 'bfmeta' ? mockBfmetaConfig : null, + getRpcUrl: () => '', + getDecimals: () => 8, + getSymbol: () => 'BFM', + getBiowalletApi: () => null, + }, +})) + describe('BioforestAdapter', () => { describe('constructor', () => { it('creates adapter with correct chainId and type', () => { diff --git a/src/services/chain-adapter/__tests__/bioforest-api.test.ts b/src/services/chain-adapter/__tests__/bioforest-api.test.ts index 7c5b41627..da658e56b 100644 --- a/src/services/chain-adapter/__tests__/bioforest-api.test.ts +++ b/src/services/chain-adapter/__tests__/bioforest-api.test.ts @@ -12,7 +12,7 @@ import { BioforestAdapter } from '../bioforest' const mockConfigWithRpc: ChainConfig = { id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFM', prefix: 'b', @@ -22,6 +22,17 @@ const mockConfigWithRpc: ChainConfig = { api: { url: 'https://walletapi.bfmeta.info', path: 'bfm' }, } +// Mock chainConfigService +vi.mock('@/services/chain-config/service', () => ({ + chainConfigService: { + getConfig: (chainId: string) => chainId === 'bfmeta' ? mockConfigWithRpc : null, + getRpcUrl: () => '', + getDecimals: () => 8, + getSymbol: () => 'BFM', + getBiowalletApi: () => ({ endpoint: 'https://walletapi.bfmeta.info', path: 'bfm' }), + }, +})) + // Helper to create mock Response function mockResponse(data: unknown, ok = true): Response { return { @@ -36,7 +47,7 @@ describe('BioForest API Response Parsing', () => { let fetchSpy: ReturnType beforeEach(() => { - adapter = new BioforestAdapter(mockConfigWithRpc) + adapter = new BioforestAdapter(mockConfigWithRpc.id) fetchSpy = vi.spyOn(globalThis, 'fetch') }) diff --git a/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts b/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts index 3f2b3ddf9..c9c263c3d 100644 --- a/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts +++ b/src/services/chain-adapter/__tests__/bitcoin-adapter.test.ts @@ -8,7 +8,7 @@ const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon a const btcConfig: ChainConfig = { id: 'bitcoin', version: '1.0', - type: 'bip39', + chainKind: 'bitcoin', name: 'Bitcoin', symbol: 'BTC', decimals: 8, diff --git a/src/services/chain-adapter/__tests__/evm-adapter.test.ts b/src/services/chain-adapter/__tests__/evm-adapter.test.ts index 5202e8c56..3c1e71614 100644 --- a/src/services/chain-adapter/__tests__/evm-adapter.test.ts +++ b/src/services/chain-adapter/__tests__/evm-adapter.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { EvmAdapter } from '../evm' import type { ChainConfig } from '@/services/chain-config' const ethConfig: ChainConfig = { id: 'ethereum', version: '1.0', - type: 'evm', + chainKind: 'evm', name: 'Ethereum', symbol: 'ETH', decimals: 18, @@ -13,8 +13,20 @@ const ethConfig: ChainConfig = { source: 'default', } +// Mock chainConfigService +vi.mock('@/services/chain-config/service', () => ({ + chainConfigService: { + getConfig: (chainId: string) => chainId === 'ethereum' ? ethConfig : null, + getRpcUrl: () => '', + getDecimals: () => 18, + getSymbol: () => 'ETH', + getEtherscanApi: () => null, + getExplorerUrl: () => null, + }, +})) + describe('EvmAdapter', () => { - const adapter = new EvmAdapter(ethConfig) + const adapter = new EvmAdapter(ethConfig.id) describe('EvmIdentityService', () => { it('validates Ethereum addresses correctly', () => { diff --git a/src/services/chain-adapter/__tests__/tron-adapter.test.ts b/src/services/chain-adapter/__tests__/tron-adapter.test.ts index 388062988..bb3051cd2 100644 --- a/src/services/chain-adapter/__tests__/tron-adapter.test.ts +++ b/src/services/chain-adapter/__tests__/tron-adapter.test.ts @@ -8,7 +8,7 @@ const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon a const tronConfig: ChainConfig = { id: 'tron', version: '1.0', - type: 'tron', + chainKind: 'tron', name: 'Tron', symbol: 'TRX', decimals: 6, diff --git a/src/services/chain-adapter/bioforest/asset-service.test.ts b/src/services/chain-adapter/bioforest/asset-service.test.ts index 946a7e53a..da37a2354 100644 --- a/src/services/chain-adapter/bioforest/asset-service.test.ts +++ b/src/services/chain-adapter/bioforest/asset-service.test.ts @@ -13,7 +13,7 @@ vi.mock('@/services/chain-config', () => ({ if (chainId === 'bfmeta') { return { id: 'bfmeta', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFM', decimals: 8, diff --git a/src/services/chain-adapter/bioforest/chain-service.ts b/src/services/chain-adapter/bioforest/chain-service.ts index 2dedbbbda..3cbdd10eb 100644 --- a/src/services/chain-adapter/bioforest/chain-service.ts +++ b/src/services/chain-adapter/bioforest/chain-service.ts @@ -10,33 +10,50 @@ import { ChainServiceError, ChainErrorCodes } from '../types' import type { BioforestBlockInfo, BioforestFeeInfo } from './types' export class BioforestChainService implements IChainService { - private readonly config: ChainConfig - private readonly apiUrl: string - private readonly apiPath: string - - constructor(config: ChainConfig) { - this.config = config - const biowalletApi = chainConfigService.getBiowalletApi(config.id) - this.apiUrl = biowalletApi?.endpoint ?? '' - this.apiPath = biowalletApi?.path ?? config.id + private readonly chainId: string + private config: ChainConfig | null = null + private apiUrl: string = '' + private apiPath: string = '' + + constructor(chainId: string) { + this.chainId = chainId + } + + private getConfig(): ChainConfig { + if (!this.config) { + const config = chainConfigService.getConfig(this.chainId) + if (!config) { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_FOUND, + `Chain config not found: ${this.chainId}`, + ) + } + this.config = config + const biowalletApi = chainConfigService.getBiowalletApi(config.id) + this.apiUrl = biowalletApi?.endpoint ?? '' + this.apiPath = biowalletApi?.path ?? config.id + } + return this.config } getChainInfo(): ChainInfo { + const config = this.getConfig() const info: ChainInfo = { - chainId: this.config.id, - name: this.config.name, - symbol: this.config.symbol, - decimals: this.config.decimals, + chainId: config.id, + name: config.name, + symbol: config.symbol, + decimals: config.decimals, blockTime: 10, // BioForest ~10s block time confirmations: 1, // BioForest usually 1 confirmation is enough } - if (this.config.explorer?.url) { - info.explorerUrl = this.config.explorer.url + if (config.explorer?.url) { + info.explorerUrl = config.explorer.url } return info } async getBlockHeight(): Promise { + this.getConfig() // Ensure config is loaded if (!this.apiUrl) { return 0n } @@ -71,7 +88,8 @@ export class BioforestChainService implements IChainService { } async getGasPrice(): Promise { - const { decimals, symbol } = this.config + const config = this.getConfig() + const { decimals, symbol } = config if (!this.apiUrl) { // Return default fees - BioForest minimum is around 500 (0.000005 BFM) @@ -131,6 +149,7 @@ export class BioforestChainService implements IChainService { } async healthCheck(): Promise { + this.getConfig() // Ensure config is loaded if (!this.apiUrl) { return { isHealthy: false, diff --git a/src/services/chain-adapter/bioforest/identity-service.ts b/src/services/chain-adapter/bioforest/identity-service.ts index 3d078ade6..f5ea558e5 100644 --- a/src/services/chain-adapter/bioforest/identity-service.ts +++ b/src/services/chain-adapter/bioforest/identity-service.ts @@ -15,20 +15,30 @@ function bytesToHex(bytes: Uint8Array): string { .join('') } import type { ChainConfig } from '@/services/chain-config' +import { chainConfigService } from '@/services/chain-config' import type { IIdentityService, Address, Signature } from '../types' export class BioforestIdentityService implements IIdentityService { - private readonly prefix: string + private readonly chainId: string + private prefix: string | null = null - constructor(config: ChainConfig) { - this.prefix = config.prefix ?? 'b' + constructor(chainId: string) { + this.chainId = chainId + } + + private getPrefix(): string { + if (!this.prefix) { + const config = chainConfigService.getConfig(this.chainId) + this.prefix = config?.prefix ?? 'b' + } + return this.prefix } async deriveAddress(seed: Uint8Array, _index = 0): Promise
{ // BioForest uses the same keypair for all indices (no HD derivation) const seedString = new TextDecoder().decode(seed) const keypair = createBioforestKeypair(seedString) - return publicKeyToBioforestAddress(keypair.publicKey, this.prefix) + return publicKeyToBioforestAddress(keypair.publicKey, this.getPrefix()) } async deriveAddresses(seed: Uint8Array, startIndex: number, count: number): Promise { diff --git a/src/services/chain-adapter/bioforest/transaction-service.ts b/src/services/chain-adapter/bioforest/transaction-service.ts index 5a3eb981f..1e8d1c313 100644 --- a/src/services/chain-adapter/bioforest/transaction-service.ts +++ b/src/services/chain-adapter/bioforest/transaction-service.ts @@ -26,19 +26,35 @@ import { signMessage, bytesToHex } from '@/lib/crypto' import { getBioforestCore, getLastBlock } from '@/services/bioforest-sdk' export class BioforestTransactionService implements ITransactionService { - private readonly config: ChainConfig - private readonly apiUrl: string - private readonly apiPath: string - - constructor(config: ChainConfig) { - this.config = config - const biowalletApi = chainConfigService.getBiowalletApi(config.id) - this.apiUrl = biowalletApi?.endpoint ?? '' - this.apiPath = biowalletApi?.path ?? config.id + private readonly chainId: string + private config: ChainConfig | null = null + private apiUrl: string = '' + private apiPath: string = '' + + constructor(chainId: string) { + this.chainId = chainId + } + + private getConfig(): ChainConfig { + if (!this.config) { + const config = chainConfigService.getConfig(this.chainId) + if (!config) { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_FOUND, + `Chain config not found: ${this.chainId}`, + ) + } + this.config = config + const biowalletApi = chainConfigService.getBiowalletApi(config.id) + this.apiUrl = biowalletApi?.endpoint ?? '' + this.apiPath = biowalletApi?.path ?? config.id + } + return this.config } async estimateFee(params: TransferParams): Promise { - const { decimals, symbol } = this.config + const config = this.getConfig() + const { decimals, symbol } = config const createFee = (amount: Amount, time: number): Fee => ({ amount, @@ -51,7 +67,7 @@ export class BioforestTransactionService implements ITransactionService { } // Use SDK to calculate minimum fee (same as mpay) - const core = await getBioforestCore(this.config.id) + const core = await getBioforestCore(config.id) const lastBlock = await getLastBlock(this.apiUrl, this.apiPath) const minFeeRaw = await core.transactionController.getTransferTransactionMinFee({ @@ -88,6 +104,7 @@ export class BioforestTransactionService implements ITransactionService { } async buildTransaction(params: TransferParams): Promise { + const config = this.getConfig() // Validate addresses if (!params.from || !params.to) { throw new ChainServiceError(ChainErrorCodes.INVALID_ADDRESS, 'Invalid address') @@ -97,13 +114,13 @@ export class BioforestTransactionService implements ITransactionService { const feeEstimate = await this.estimateFee(params) return { - chainId: this.config.id, + chainId: config.id, data: { type: 'transfer', from: params.from, to: params.to, amount: params.amount.toRawString(), - assetType: this.config.symbol, + assetType: config.symbol, fee: feeEstimate.standard.amount.toRawString(), memo: params.memo, timestamp: Date.now(), @@ -146,6 +163,7 @@ export class BioforestTransactionService implements ITransactionService { } async broadcastTransaction(signedTx: SignedTransaction): Promise { + this.getConfig() // Ensure config is loaded if (!this.apiUrl) { throw new ChainServiceError( ChainErrorCodes.NETWORK_ERROR, @@ -192,6 +210,7 @@ export class BioforestTransactionService implements ITransactionService { } async getTransactionStatus(hash: TransactionHash): Promise { + this.getConfig() // Ensure config is loaded if (!this.apiUrl) { return { status: 'pending', @@ -246,6 +265,7 @@ export class BioforestTransactionService implements ITransactionService { } async getTransaction(hash: TransactionHash): Promise { + this.getConfig() // Ensure config is loaded if (!this.apiUrl) { return null } @@ -314,8 +334,9 @@ export class BioforestTransactionService implements ITransactionService { } async getTransactionHistory(address: Address, limit = 20): Promise { + const config = this.getConfig() if (!this.apiUrl) { - console.warn('[TransactionService] No baseUrl configured for chain:', this.config.id) + console.warn('[TransactionService] No baseUrl configured for chain:', config.id) return [] } @@ -388,11 +409,11 @@ export class BioforestTransactionService implements ITransactionService { const transactions = json.result?.trs ?? [] if (transactions.length === 0) { - console.debug('[TransactionService] No transactions found for', address, 'on', this.config.id) + console.debug('[TransactionService] No transactions found for', address, 'on', config.id) return [] } - const { decimals, symbol } = this.config + const { decimals, symbol } = config // Show all transaction types for the address return transactions diff --git a/src/services/chain-adapter/bip39/types.ts b/src/services/chain-adapter/bip39/types.ts index 04f21b0bd..ca12140bc 100644 --- a/src/services/chain-adapter/bip39/types.ts +++ b/src/services/chain-adapter/bip39/types.ts @@ -5,7 +5,7 @@ import type { ChainConfig } from '@/services/chain-config' export interface Bip39ChainConfig extends ChainConfig { - type: 'bip39' + chainKind: 'bitcoin' } export type Bip39ChainId = 'bitcoin' | 'tron' diff --git a/src/services/chain-adapter/bitcoin/identity-service.ts b/src/services/chain-adapter/bitcoin/identity-service.ts index 62632627b..a8ab97930 100644 --- a/src/services/chain-adapter/bitcoin/identity-service.ts +++ b/src/services/chain-adapter/bitcoin/identity-service.ts @@ -7,7 +7,6 @@ * - P2PKH (Legacy, 1...) - BIP44 */ -import type { ChainConfig } from '@/services/chain-config' import type { IIdentityService, Address, Signature } from '../types' import { sha256 } from '@noble/hashes/sha2.js' import { ripemd160 } from '@noble/hashes/legacy.js' @@ -17,7 +16,7 @@ import { HDKey } from '@scure/bip32' import { bech32, bech32m, base58check } from '@scure/base' export class BitcoinIdentityService implements IIdentityService { - constructor(_config: ChainConfig) {} + constructor(_chainId: string) {} async deriveAddress(seed: Uint8Array, index = 0): Promise
{ // Default to Native SegWit (P2WPKH) - BIP84: m/84'/0'/0'/0/index diff --git a/src/services/chain-adapter/evm/types.ts b/src/services/chain-adapter/evm/types.ts index 98cc6cba8..c2f4e7a90 100644 --- a/src/services/chain-adapter/evm/types.ts +++ b/src/services/chain-adapter/evm/types.ts @@ -5,7 +5,7 @@ import type { ChainConfig } from '@/services/chain-config' export interface EvmChainConfig extends ChainConfig { - type: 'evm' + chainKind: 'evm' /** EVM Chain ID (e.g., 1 for Ethereum mainnet, 56 for BSC) */ chainId?: number /** RPC endpoint URL */ diff --git a/src/services/chain-adapter/providers/__tests__/integration.test.ts b/src/services/chain-adapter/providers/__tests__/integration.test.ts index 0d91dfdfc..8dfaa8c90 100644 --- a/src/services/chain-adapter/providers/__tests__/integration.test.ts +++ b/src/services/chain-adapter/providers/__tests__/integration.test.ts @@ -29,7 +29,7 @@ describe('ChainProvider 集成测试', () => { const mockEthConfig: ChainConfig = { id: 'ethereum', version: '1.0', - type: 'evm', + chainKind: 'evm', name: 'Ethereum', symbol: 'ETH', decimals: 18, @@ -65,7 +65,7 @@ describe('ChainProvider 集成测试', () => { const mockBfmetaConfig: ChainConfig = { id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFM', decimals: 8, @@ -106,7 +106,7 @@ describe('ChainProvider 集成测试', () => { const mockConfig: ChainConfig = { id: 'test', version: '1.0', - type: 'evm', + chainKind: 'evm', name: 'Test', symbol: 'TEST', decimals: 18, diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index b18c44a06..ea32ad1eb 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -75,7 +75,7 @@ function createApiProvider(entry: ParsedApiEntry, chainId: string): ApiProvider function createWrappedProviders(config: ChainConfig): ApiProvider[] { const providers: ApiProvider[] = [] - switch (config.type) { + switch (config.chainKind) { case 'evm': { const identity = new EvmIdentityService(config.id) const asset = new EvmAssetService(config.id) @@ -87,9 +87,9 @@ function createWrappedProviders(config: ChainConfig): ApiProvider[] { break } case 'tron': { - const identity = new TronIdentityService(config) + const identity = new TronIdentityService(config.id) const asset = new TronAssetService(config.id) - const transaction = new TronTransactionService(config) + const transaction = new TronTransactionService(config.id) providers.push( new WrappedTransactionProvider(`wrapped-tron-tx`, transaction, asset), new WrappedIdentityProvider(`wrapped-tron-identity`, identity), @@ -97,7 +97,7 @@ function createWrappedProviders(config: ChainConfig): ApiProvider[] { break } case 'bitcoin': { - const identity = new BitcoinIdentityService(config) + const identity = new BitcoinIdentityService(config.id) const asset = new BitcoinAssetService(config.id) const transaction = new BitcoinTransactionService(config.id) providers.push( @@ -107,23 +107,17 @@ function createWrappedProviders(config: ChainConfig): ApiProvider[] { break } case 'bioforest': { - const identity = new BioforestIdentityService(config) + const identity = new BioforestIdentityService(config.id) const asset = new BioforestAssetService(config.id) - const transaction = new BioforestTransactionService(config) + const transaction = new BioforestTransactionService(config.id) providers.push( new WrappedTransactionProvider(`wrapped-bioforest-tx`, transaction, asset), new WrappedIdentityProvider(`wrapped-bioforest-identity`, identity), ) break } - case 'bip39': { - const identity = new Bip39IdentityService(config) - const asset = new Bip39AssetService(config.id) - const transaction = new Bip39TransactionService(config) - providers.push( - new WrappedTransactionProvider(`wrapped-bip39-tx`, transaction, asset), - new WrappedIdentityProvider(`wrapped-bip39-identity`, identity), - ) + case 'custom': { + // Custom chains not supported yet - skip break } } diff --git a/src/services/chain-adapter/tron/identity-service.ts b/src/services/chain-adapter/tron/identity-service.ts index e6a3880c7..0971f618c 100644 --- a/src/services/chain-adapter/tron/identity-service.ts +++ b/src/services/chain-adapter/tron/identity-service.ts @@ -2,7 +2,6 @@ * Tron Identity Service */ -import type { ChainConfig } from '@/services/chain-config' import type { IIdentityService, Address, Signature } from '../types' import { sha256 } from '@noble/hashes/sha2.js' import { secp256k1 } from '@noble/curves/secp256k1.js' @@ -14,7 +13,7 @@ import { HDKey } from '@scure/bip32' const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' export class TronIdentityService implements IIdentityService { - constructor(_config: ChainConfig) {} + constructor(_chainId: string) {} async deriveAddress(seed: Uint8Array, index = 0): Promise
{ // Tron uses BIP44 path: m/44'/195'/0'/0/index diff --git a/src/services/chain-adapter/tron/transaction-service.ts b/src/services/chain-adapter/tron/transaction-service.ts index 502597bb4..4c90e1cb0 100644 --- a/src/services/chain-adapter/tron/transaction-service.ts +++ b/src/services/chain-adapter/tron/transaction-service.ts @@ -32,13 +32,27 @@ import type { const DEFAULT_RPC_URL = 'https://api.trongrid.io' export class TronTransactionService implements ITransactionService { - private readonly config: ChainConfig - private readonly rpcUrl: string + private readonly chainId: string + private config: ChainConfig | null = null + private rpcUrl: string = '' - constructor(config: ChainConfig) { - this.config = config - // 使用 *-rpc API 配置 - this.rpcUrl = chainConfigService.getRpcUrl(config.id) || DEFAULT_RPC_URL + constructor(chainId: string) { + this.chainId = chainId + } + + private getConfig(): ChainConfig { + if (!this.config) { + const config = chainConfigService.getConfig(this.chainId) + if (!config) { + throw new ChainServiceError( + ChainErrorCodes.CHAIN_NOT_FOUND, + `Chain config not found: ${this.chainId}`, + ) + } + this.config = config + this.rpcUrl = chainConfigService.getRpcUrl(config.id) || DEFAULT_RPC_URL + } + return this.config } private async api(endpoint: string, body?: unknown): Promise { @@ -77,9 +91,10 @@ export class TronTransactionService implements ITransactionService { } async estimateFee(_params: TransferParams): Promise { + const config = this.getConfig() // TRX transfers typically cost bandwidth (free up to limit) // If bandwidth exhausted, burns TRX at 1000 SUN per bandwidth unit - const feeAmount = Amount.fromRaw('0', this.config.decimals, this.config.symbol) + const feeAmount = Amount.fromRaw('0', config.decimals, config.symbol) const fee: Fee = { amount: feeAmount, @@ -94,6 +109,7 @@ export class TronTransactionService implements ITransactionService { } async buildTransaction(params: TransferParams): Promise { + const config = this.getConfig() // Convert addresses to hex format for API const ownerAddressHex = this.base58ToHex(params.from) const toAddressHex = this.base58ToHex(params.to) @@ -114,7 +130,7 @@ export class TronTransactionService implements ITransactionService { } return { - chainId: this.config.id, + chainId: config.id, data: rawTx, } } @@ -138,7 +154,7 @@ export class TronTransactionService implements ITransactionService { } return { - chainId: this.config.id, + chainId: unsignedTx.chainId, data: signedTx, signature: signature, } @@ -203,16 +219,17 @@ export class TronTransactionService implements ITransactionService { const { amount, owner_address, to_address } = contract.parameter.value const isConfirmed = 'blockNumber' in info + const config = this.getConfig() return { hash: tx.txID, from: owner_address, to: to_address, - amount: Amount.fromRaw(amount.toString(), this.config.decimals, this.config.symbol), + amount: Amount.fromRaw(amount.toString(), config.decimals, config.symbol), fee: Amount.fromRaw( ((info as TronTransactionInfo).receipt?.net_usage ?? 0).toString(), - this.config.decimals, - this.config.symbol, + config.decimals, + config.symbol, ), status: { status: isConfirmed ? 'confirmed' : 'pending', diff --git a/src/services/chain-config/__tests__/index.test.ts b/src/services/chain-config/__tests__/index.test.ts index f802344ba..2f6242b41 100644 --- a/src/services/chain-config/__tests__/index.test.ts +++ b/src/services/chain-config/__tests__/index.test.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it } from 'vitest' import 'fake-indexeddb/auto' import { ChainConfigSchema, ChainConfigSubscriptionSchema } from '../schema' -import { resetChainConfigStorageForTests, saveChainConfigs, saveSubscriptionMeta, saveUserPreferences } from '../storage' +import { resetChainConfigStorageForTests, saveChainConfigs, saveSubscriptionMeta, saveUserPreferences, saveDefaultVersion } from '../storage' import { addManualConfig, getChainById, getEnabledChains, initialize, setChainEnabled } from '../index' describe('chain-config service', () => { beforeEach(async () => { await resetChainConfigStorageForTests() + // Initialize default version to prevent migration error + await saveDefaultVersion('2.0.0') }) it('merges sources with precedence manual > subscription > default', async () => { @@ -24,7 +26,7 @@ describe('chain-config service', () => { ChainConfigSchema.parse({ id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta (sub)', symbol: 'BFT', decimals: 8, @@ -40,7 +42,7 @@ describe('chain-config service', () => { ChainConfigSchema.parse({ id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta (manual)', symbol: 'BFT', decimals: 8, @@ -73,7 +75,7 @@ describe('chain-config service', () => { ChainConfigSchema.parse({ id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta (sub)', symbol: 'BFT', decimals: 8, @@ -106,7 +108,7 @@ describe('chain-config service', () => { ChainConfigSchema.parse({ id: 'future', version: '2.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'Future', symbol: 'FUT', decimals: 8, @@ -128,7 +130,7 @@ describe('chain-config service', () => { await addManualConfig({ id: 'manual-one', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'Manual One', symbol: 'M1', decimals: 8, @@ -139,7 +141,7 @@ describe('chain-config service', () => { { id: 'manual-two', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'Manual Two', symbol: 'M2', decimals: 8, @@ -152,18 +154,18 @@ describe('chain-config service', () => { expect(getChainById(snapshot, 'manual-two')?.source).toBe('manual') }) - it('normalizes unknown type to custom when adding manual config', async () => { + it('normalizes unknown chainKind to custom when adding manual config', async () => { const snapshot = await addManualConfig({ id: 'manual-unknown', version: '1.0', - type: 'unknown-type', + chainKind: 'unknown-kind' as any, name: 'Manual Unknown', symbol: 'MU', decimals: 8, }) const chain = getChainById(snapshot, 'manual-unknown') - expect(chain?.type).toBe('custom') + expect(chain?.chainKind).toBe('custom') expect(chain?.source).toBe('manual') }) diff --git a/src/services/chain-config/__tests__/refresh-subscription.test.ts b/src/services/chain-config/__tests__/refresh-subscription.test.ts index 99e94d7a2..6220b8075 100644 --- a/src/services/chain-config/__tests__/refresh-subscription.test.ts +++ b/src/services/chain-config/__tests__/refresh-subscription.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import 'fake-indexeddb/auto' import { ChainConfigSubscriptionSchema } from '../schema' -import { loadChainConfigs, loadSubscriptionMeta, resetChainConfigStorageForTests, saveSubscriptionMeta } from '../storage' +import { loadChainConfigs, loadSubscriptionMeta, resetChainConfigStorageForTests, saveSubscriptionMeta, saveDefaultVersion } from '../storage' import { refreshSubscription } from '../index' describe('chain-config refreshSubscription', () => { @@ -10,6 +10,7 @@ describe('chain-config refreshSubscription', () => { vi.restoreAllMocks() vi.unstubAllGlobals() await resetChainConfigStorageForTests() + await saveDefaultVersion('2.0.0') }) it('skips when subscription url is default', async () => { @@ -43,7 +44,7 @@ describe('chain-config refreshSubscription', () => { return new Response( JSON.stringify([ - { id: 'remote-one', version: '1.0', type: 'custom', name: 'Remote One', symbol: 'R1', decimals: 8 }, + { id: 'remote-one', version: '1.0', chainKind: 'custom', name: 'Remote One', symbol: 'R1', decimals: 8 }, ]), { status: 200, headers: { 'Content-Type': 'application/json', ETag: 'etag-2' } } ) diff --git a/src/services/chain-config/__tests__/schema.test.ts b/src/services/chain-config/__tests__/schema.test.ts index c22c5c3eb..ebc9df287 100644 --- a/src/services/chain-config/__tests__/schema.test.ts +++ b/src/services/chain-config/__tests__/schema.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest' import fs from 'node:fs/promises' import path from 'node:path' -import { ChainConfigListSchema, ChainConfigSchema } from '../schema' +import { ChainConfigListSchema, ChainConfigSchema, VersionedChainConfigFileSchema } from '../schema' describe('ChainConfigSchema', () => { it('fills runtime defaults (enabled/source)', () => { const parsed = ChainConfigSchema.parse({ id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFT', prefix: 'c', @@ -25,7 +25,7 @@ describe('ChainConfigSchema', () => { ChainConfigSchema.parse({ id: 'bfmeta', version: '1', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFT', prefix: 'c', @@ -41,7 +41,8 @@ describe('default-chains.json', () => { const raw = await fs.readFile(filePath, 'utf8') const parsedJson: unknown = JSON.parse(raw) - const chains = ChainConfigListSchema.parse(parsedJson) + const versionedFile = VersionedChainConfigFileSchema.parse(parsedJson) + const chains = versionedFile.chains // 7 bioforest + 4 external (ethereum, binance, tron, bitcoin) expect(chains).toHaveLength(11) @@ -62,10 +63,10 @@ describe('default-chains.json', () => { ]) // Verify chain types - const bioforestChains = chains.filter(c => c.type === 'bioforest') - const evmChains = chains.filter(c => c.type === 'evm') - const tronChains = chains.filter(c => c.type === 'tron') - const bip39Chains = chains.filter(c => c.type === 'bip39') + const bioforestChains = chains.filter(c => c.chainKind === 'bioforest') + const evmChains = chains.filter(c => c.chainKind === 'evm') + const tronChains = chains.filter(c => c.chainKind === 'tron') + const bip39Chains = chains.filter(c => c.chainKind === 'bitcoin') expect(bioforestChains).toHaveLength(7) expect(evmChains).toHaveLength(2) diff --git a/src/services/chain-config/__tests__/set-subscription-url.test.ts b/src/services/chain-config/__tests__/set-subscription-url.test.ts index fb7bd3402..5b08eda39 100644 --- a/src/services/chain-config/__tests__/set-subscription-url.test.ts +++ b/src/services/chain-config/__tests__/set-subscription-url.test.ts @@ -8,12 +8,14 @@ import { resetChainConfigStorageForTests, saveChainConfigs, saveSubscriptionMeta, + saveDefaultVersion, } from '../storage' import { setSubscriptionUrl } from '../index' describe('chain-config setSubscriptionUrl', () => { beforeEach(async () => { await resetChainConfigStorageForTests() + await saveDefaultVersion('2.0.0') }) it('accepts empty string as default and clears cached subscription configs', async () => { @@ -30,7 +32,7 @@ describe('chain-config setSubscriptionUrl', () => { ChainConfigSchema.parse({ id: 'cached', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'Cached', symbol: 'C', decimals: 8, @@ -63,7 +65,7 @@ describe('chain-config setSubscriptionUrl', () => { ChainConfigSchema.parse({ id: 'cached', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'Cached', symbol: 'C', decimals: 8, diff --git a/src/services/chain-config/__tests__/storage.test.ts b/src/services/chain-config/__tests__/storage.test.ts index 6fda426d4..ed29752eb 100644 --- a/src/services/chain-config/__tests__/storage.test.ts +++ b/src/services/chain-config/__tests__/storage.test.ts @@ -20,7 +20,7 @@ describe('chain-config storage', () => { ChainConfigSchema.parse({ id: 'bfmeta', version: '1.0', - type: 'bioforest', + chainKind: 'bioforest', name: 'BFMeta', symbol: 'BFT', prefix: 'c', @@ -29,7 +29,7 @@ describe('chain-config storage', () => { ChainConfigSchema.parse({ id: 'custom-1', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'Custom', symbol: 'CST', decimals: 8, @@ -49,7 +49,7 @@ describe('chain-config storage', () => { ChainConfigSchema.parse({ id: 'first', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'First', symbol: 'FST', decimals: 8, @@ -61,7 +61,7 @@ describe('chain-config storage', () => { ChainConfigSchema.parse({ id: 'second', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'Second', symbol: 'SND', decimals: 8, diff --git a/src/services/chain-config/__tests__/subscription.test.ts b/src/services/chain-config/__tests__/subscription.test.ts index 4d999e60c..a16c38549 100644 --- a/src/services/chain-config/__tests__/subscription.test.ts +++ b/src/services/chain-config/__tests__/subscription.test.ts @@ -16,7 +16,7 @@ describe('chain-config subscription', () => { const single = parseAndValidate({ id: 'one', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'One', symbol: 'ONE', decimals: 8, @@ -28,7 +28,7 @@ describe('chain-config subscription', () => { { id: 'two', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'Two', symbol: 'TWO', decimals: 8, @@ -52,7 +52,7 @@ describe('chain-config subscription', () => { ChainConfigSchema.parse({ id: 'cached', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'Cached', symbol: 'C', decimals: 8, @@ -83,7 +83,7 @@ describe('chain-config subscription', () => { const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(async () => { return new Response( JSON.stringify([ - { id: 'new', version: '1.0', type: 'custom', name: 'New', symbol: 'NEW', decimals: 8 }, + { id: 'new', version: '1.0', chainKind: 'custom', name: 'New', symbol: 'NEW', decimals: 8 }, ]), { status: 200, @@ -111,7 +111,7 @@ describe('chain-config subscription', () => { ChainConfigSchema.parse({ id: 'cached', version: '1.0', - type: 'custom', + chainKind: 'custom', name: 'Cached', symbol: 'C', decimals: 8, diff --git a/src/services/chain-config/index.ts b/src/services/chain-config/index.ts index 9962920dc..d3e3dee51 100644 --- a/src/services/chain-config/index.ts +++ b/src/services/chain-config/index.ts @@ -1,4 +1,4 @@ -export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType, ParsedApiEntry, ApiEntry, ApiConfig } from './types' +export type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainKind, ParsedApiEntry, ApiEntry, ApiConfig } from './types' export { chainConfigService } from './service' import { ChainConfigListSchema, ChainConfigSchema, ChainConfigSubscriptionSchema, VersionedChainConfigFileSchema } from './schema' @@ -13,7 +13,7 @@ import { loadDefaultVersion, saveDefaultVersion, } from './storage' -import type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainConfigType } from './types' +import type { ChainConfig, ChainConfigSource, ChainConfigSubscription, ChainKind } from './types' /** 数据库版本不兼容错误,需要用户清空数据 */ export class ChainConfigMigrationError extends Error { @@ -44,13 +44,13 @@ export interface ChainConfigSnapshot { warnings: ChainConfigWarning[] } -const KNOWN_TYPES: ReadonlySet = new Set(['bioforest', 'evm', 'tron', 'bip39', 'custom']) +const KNOWN_KINDS: ReadonlySet = new Set(['bioforest', 'evm', 'bitcoin', 'tron', 'custom']) -const SUPPORTED_MAJOR_BY_TYPE: Record = { +const SUPPORTED_MAJOR_BY_KIND: Record = { bioforest: 1, evm: 1, + bitcoin: 1, tron: 1, - bip39: 1, custom: 1, } @@ -64,13 +64,13 @@ interface DefaultChainsResult { let defaultChainsCache: DefaultChainsResult | null = null let defaultChainsLoading: Promise | null = null -function normalizeUnknownType(input: unknown): unknown { +function normalizeUnknownKind(input: unknown): unknown { if (typeof input !== 'object' || input === null || Array.isArray(input)) return input const record = input as Record - const type = record.type - if (typeof type === 'string' && !KNOWN_TYPES.has(type)) { - return { ...record, type: 'custom' } + const chainKind = record.chainKind + if (typeof chainKind === 'string' && !KNOWN_KINDS.has(chainKind)) { + return { ...record, chainKind: 'custom' } } return input @@ -86,7 +86,7 @@ function parseMajor(version: string): number | null { function isCompatible(config: ChainConfig): boolean { const major = parseMajor(config.version) if (major === null) return false - return major <= SUPPORTED_MAJOR_BY_TYPE[config.type] + return major <= SUPPORTED_MAJOR_BY_KIND[config.chainKind] } function getDefaultChainsUrl(): string { @@ -116,7 +116,7 @@ async function loadDefaultChainConfigs(): Promise { const json: unknown = await response.json() const parsed = VersionedChainConfigFileSchema.parse(json) - const configs = parsed.chains.map((chain) => normalizeUnknownType(chain)).map((chain) => { + const configs = parsed.chains.map((chain) => normalizeUnknownKind(chain)).map((chain) => { const config = ChainConfigSchema.parse(chain) const resolvedPaths = resolveIconPaths(config, jsonUrl) return { @@ -185,7 +185,7 @@ function resolveIconPaths( } function parseConfigs(input: unknown, source: ChainConfigSource, jsonFileUrl?: string): ChainConfig[] { - const normalized: unknown = Array.isArray(input) ? input.map(normalizeUnknownType) : normalizeUnknownType(input) + const normalized: unknown = Array.isArray(input) ? input.map(normalizeUnknownKind) : normalizeUnknownKind(input) const parsed = Array.isArray(normalized) ? ChainConfigListSchema.parse(normalized) @@ -238,7 +238,7 @@ function collectWarnings(configs: ChainConfig[]): ChainConfigWarning[] { const major = parseMajor(config.version) if (major === null) continue - const supportedMajor = SUPPORTED_MAJOR_BY_TYPE[config.type] + const supportedMajor = SUPPORTED_MAJOR_BY_KIND[config.chainKind] if (major > supportedMajor) { warnings.push({ id: config.id, diff --git a/src/services/chain-config/schema.ts b/src/services/chain-config/schema.ts index 1abad2e95..98ee86f27 100644 --- a/src/services/chain-config/schema.ts +++ b/src/services/chain-config/schema.ts @@ -2,12 +2,12 @@ * 链配置 Zod Schema * * 设计原则:分离链属性与提供商配置 - * - 链属性:id, type, name, symbol, prefix, decimals(链的固有属性) + * - 链属性:id, chainKind, name, symbol, prefix, decimals(链的固有属性) * - 提供商配置:api, explorer(外部依赖,可替换) * * 说明: * - `version` 使用 `major.minor`(例如 "1.0") - * - `type` 用于选择对应的链服务实现(bioforest/evm/bip39/custom) + * - `chainKind` 用于选择对应的链派生策略与服务实现(bioforest/evm/bitcoin/tron/custom) * - `source/enabled` 为运行时字段:用于 UI 展示与用户启用状态 */ @@ -17,7 +17,7 @@ export const ChainConfigVersionSchema = z .string() .regex(/^\d+\.\d+$/, 'version must be "major.minor" (e.g. "1.0")') -export const ChainConfigTypeSchema = z.enum(['bioforest', 'evm', 'tron', 'bip39', 'custom']) +export const ChainKindSchema = z.enum(['bioforest', 'evm', 'bitcoin', 'tron', 'custom']) export const ChainConfigSourceSchema = z.enum(['default', 'subscription', 'manual']) @@ -60,7 +60,7 @@ export const ChainConfigSchema = z // ===== 链固有属性 ===== id: z.string().regex(/^[a-z0-9-]+$/, 'id must match /^[a-z0-9-]+$/'), version: ChainConfigVersionSchema, - type: ChainConfigTypeSchema, + chainKind: ChainKindSchema, name: z.string().min(1).max(50), symbol: z.string().min(1).max(10), diff --git a/src/services/chain-config/storage.ts b/src/services/chain-config/storage.ts index 47323cd8a..00ee5da14 100644 --- a/src/services/chain-config/storage.ts +++ b/src/services/chain-config/storage.ts @@ -120,7 +120,7 @@ export async function saveChainConfigs(options: { const base: BaseChainConfig = { id: config.id, version: config.version, - type: config.type, + chainKind: config.chainKind, name: config.name, symbol: config.symbol, decimals: config.decimals, diff --git a/src/services/chain-config/subscription.ts b/src/services/chain-config/subscription.ts index 9bc5fcd80..5342ac0bd 100644 --- a/src/services/chain-config/subscription.ts +++ b/src/services/chain-config/subscription.ts @@ -36,7 +36,7 @@ function normalizeUnknownType(input: unknown): unknown { const type = record.type if (typeof type === 'string' && !KNOWN_TYPES.has(type as never)) { - return { ...record, type: 'custom' } + return { ...record, chainKind: 'custom' } } return input diff --git a/src/services/chain-config/types.ts b/src/services/chain-config/types.ts index d8fa4f5b8..f050dcc75 100644 --- a/src/services/chain-config/types.ts +++ b/src/services/chain-config/types.ts @@ -13,12 +13,12 @@ import { ChainConfigSchema, ChainConfigSourceSchema, ChainConfigSubscriptionSchema, - ChainConfigTypeSchema, + ChainKindSchema, ChainConfigVersionSchema, } from './schema' export type ChainConfigVersion = z.infer -export type ChainConfigType = z.infer +export type ChainKind = z.infer export type ChainConfigSource = z.infer export type ChainConfig = z.infer diff --git a/src/services/ecosystem/handlers/transaction.ts b/src/services/ecosystem/handlers/transaction.ts index e419f402f..3a4f9aae7 100644 --- a/src/services/ecosystem/handlers/transaction.ts +++ b/src/services/ecosystem/handlers/transaction.ts @@ -153,13 +153,13 @@ export async function signUnsignedTransaction(params: { const mnemonic = await (await import('@/services/wallet-storage')).walletStorageService.getMnemonic(params.walletId, params.password) let privateKeyBytes: Uint8Array - if (chainConfig.type === 'evm') { + if (chainConfig.chainKind === 'evm') { const derived = deriveKey(mnemonic, 'ethereum', 0, 0) if (derived.address.toLowerCase() !== params.from.toLowerCase()) { throw Object.assign(new Error('Signing address mismatch'), { code: BioErrorCodes.INVALID_PARAMS }) } privateKeyBytes = hexToBytes(derived.privateKey) - } else if (chainConfig.type === 'bioforest') { + } else if (chainConfig.chainKind === 'bioforest') { const keypair = createBioforestKeypair(mnemonic) const derivedAddress = publicKeyToBioforestAddress(keypair.publicKey, chainConfig.prefix ?? 'b') if (derivedAddress !== params.from) { @@ -167,7 +167,7 @@ export async function signUnsignedTransaction(params: { } privateKeyBytes = keypair.secretKey } else { - throw Object.assign(new Error(`Unsupported chain type: ${chainConfig.type}`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) + throw Object.assign(new Error(`Unsupported chain kind: ${chainConfig.chainKind}`), { code: BioErrorCodes.UNSUPPORTED_METHOD }) } const signed = await chainProvider.signTransaction!( diff --git a/src/services/transaction/web.test.ts b/src/services/transaction/web.test.ts new file mode 100644 index 000000000..c64ecd2f7 --- /dev/null +++ b/src/services/transaction/web.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + mockWalletStorageInitialize: vi.fn(), + mockGetWalletChainAddresses: vi.fn(), + mockInitializeChainConfigs: vi.fn(), + mockGetEnabledChains: vi.fn(), + mockGetChainById: vi.fn(), + mockCreateBioforestAdapter: vi.fn(), + mockGetTransactionHistory: vi.fn(), + mockGetChainProvider: vi.fn(), +})) + +vi.mock('@/services/wallet-storage', () => ({ + walletStorageService: { + initialize: mocks.mockWalletStorageInitialize, + getWalletChainAddresses: mocks.mockGetWalletChainAddresses, + }, +})) + +vi.mock('@/services/chain-config', () => ({ + initialize: mocks.mockInitializeChainConfigs, + getEnabledChains: mocks.mockGetEnabledChains, + getChainById: mocks.mockGetChainById, +})) + +vi.mock('@/services/chain-adapter', () => ({ + createBioforestAdapter: mocks.mockCreateBioforestAdapter, +})) + +vi.mock('@/services/chain-adapter/providers', () => ({ + getChainProvider: mocks.mockGetChainProvider, +})) + +import { transactionService } from './web' + +describe('transactionService(web)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads EVM history via ChainProvider.getTransactionHistory', async () => { + mocks.mockWalletStorageInitialize.mockResolvedValue(undefined) + mocks.mockGetWalletChainAddresses.mockResolvedValue([ + { + chain: 'ethereum', + address: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', + }, + ]) + + mocks.mockInitializeChainConfigs.mockResolvedValue({}) + + const ethereumConfig = { + id: 'ethereum', + enabled: true, + chainKind: 'evm', + symbol: 'ETH', + decimals: 18, + } + mocks.mockGetEnabledChains.mockReturnValue([ethereumConfig]) + mocks.mockGetChainById.mockReturnValue(null) + + mocks.mockGetChainProvider.mockReturnValue({ + supportsTransactionHistory: true, + getTransactionHistory: mocks.mockGetTransactionHistory, + }) + + mocks.mockGetTransactionHistory.mockResolvedValue([ + { + hash: '0xdeadbeef', + from: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', + to: '0xBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBb', + value: '1000000000000000000', + symbol: 'ETH', + timestamp: 1_700_000_000_000, + status: 'confirmed', + blockNumber: 123n, + }, + ]) + + const records = await transactionService.getHistory({ + walletId: 'w1', + filter: { chain: 'ethereum', period: 'all', type: 'all', status: 'all' } as any, + }) + + expect(records).toHaveLength(1) + expect(records[0]?.id).toBe('ethereum--0xdeadbeef') + expect(records[0]?.chain).toBe('ethereum') + expect(records[0]?.type).toBe('send') + expect(records[0]?.status).toBe('confirmed') + expect(records[0]?.amount.toRawString()).toBe('1000000000000000000') + expect(records[0]?.blockNumber).toBe(123) + + const cached = await transactionService.getTransaction({ id: 'ethereum--0xdeadbeef' }) + expect(cached?.id).toBe('ethereum--0xdeadbeef') + }) +}) diff --git a/src/services/transaction/web.ts b/src/services/transaction/web.ts index 7ba83be82..b326dcad1 100644 --- a/src/services/transaction/web.ts +++ b/src/services/transaction/web.ts @@ -6,6 +6,8 @@ import { transactionServiceMeta, type TransactionFilter, type TransactionRecord, import { walletStorageService } from '@/services/wallet-storage' import { initialize as initializeChainConfigs, getEnabledChains, getChainById, type ChainConfig } from '@/services/chain-config' import { createBioforestAdapter, type Transaction as ChainTransaction } from '@/services/chain-adapter' +import { getChainProvider, type Transaction as ProviderTransaction } from '@/services/chain-adapter/providers' +import { Amount } from '@/types/amount' const recordCache = new Map() type TransactionFilterInput = Partial | undefined @@ -24,12 +26,25 @@ async function fetchHistory(walletId: string, filter?: TransactionFilterInput): if (targetChain && addressInfo.chain !== targetChain) return [] const config = enabledMap.get(addressInfo.chain) ?? getChainById(snapshot, addressInfo.chain) - if (!config || !config.enabled || config.type !== 'bioforest') return [] - - const adapter = createBioforestAdapter(config) - return adapter.transaction.getTransactionHistory(addressInfo.address, 50).then((list) => - list.map((tx) => mapChainTransaction(tx, config, addressInfo.address)) - ) + if (!config || !config.enabled) return [] + + if (config.chainKind === 'bioforest') { + const adapter = createBioforestAdapter(config.id) + return adapter.transaction.getTransactionHistory(addressInfo.address, 50).then((list) => + list.map((tx) => mapChainTransaction(tx, config, addressInfo.address)) + ) + } + + try { + const provider = getChainProvider(addressInfo.chain) + if (!provider.supportsTransactionHistory || !provider.getTransactionHistory) return [] + + return provider.getTransactionHistory(addressInfo.address, 50).then((list) => + list.map((tx) => mapProviderTransaction(tx, config, addressInfo.address)) + ) + } catch { + return [] + } }) const results = await Promise.all(tasks) @@ -125,6 +140,37 @@ function mapChainTransaction(tx: ChainTransaction, config: ChainConfig, address: } } +function isSameAddress(left: string, right: string): boolean { + const a = left.trim() + const b = right.trim() + if (a.startsWith('0x') && b.startsWith('0x')) return a.toLowerCase() === b.toLowerCase() + return a === b +} + +function mapProviderTransaction(tx: ProviderTransaction, config: ChainConfig, address: string): TransactionRecord { + const isOutgoing = isSameAddress(tx.from, address) + const type: TransactionType = isOutgoing ? 'send' : 'receive' + + return { + // Use '--' as separator to avoid URL routing conflicts with ':' + id: `${config.id}--${tx.hash}`, + type, + status: tx.status, + amount: Amount.fromRaw(tx.value, config.decimals, config.symbol), + symbol: config.symbol, + decimals: config.decimals, + address: type === 'send' ? tx.to : tx.from, + timestamp: new Date(tx.timestamp), + hash: tx.hash, + chain: config.id, + blockNumber: tx.blockNumber ? Number(tx.blockNumber) : undefined, + confirmations: undefined, + fee: undefined, + feeSymbol: undefined, + feeDecimals: undefined, + } +} + function filterByPeriod(records: TransactionRecord[], period: TransactionFilter['period']): TransactionRecord[] { if (!period || period === 'all') return records const days = period === '7d' ? 7 : period === '30d' ? 30 : 90 diff --git a/src/services/wallet-storage/__tests__/wallet-storage.test.ts b/src/services/wallet-storage/__tests__/wallet-storage.test.ts index 1bf0a06df..99399aee6 100644 --- a/src/services/wallet-storage/__tests__/wallet-storage.test.ts +++ b/src/services/wallet-storage/__tests__/wallet-storage.test.ts @@ -48,7 +48,7 @@ describe('WalletStorageService', () => { const metadata = await service.getMetadata() expect(metadata).toBeDefined() - expect(metadata?.version).toBe(1) + expect(metadata?.version).toBe(2) expect(metadata?.createdAt).toBeGreaterThan(0) }) }) diff --git a/src/services/wallet/chain-derivation/build-wallet-chain-addresses.test.ts b/src/services/wallet/chain-derivation/build-wallet-chain-addresses.test.ts new file mode 100644 index 000000000..e547f5906 --- /dev/null +++ b/src/services/wallet/chain-derivation/build-wallet-chain-addresses.test.ts @@ -0,0 +1,215 @@ +/** + * buildWalletChainAddresses Tests + */ + +import { describe, it, expect } from 'vitest' +import { buildWalletChainAddresses } from './build-wallet-chain-addresses' +import type { ChainConfig } from '@/services/chain-config' + +const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +const mockChainConfigs: ChainConfig[] = [ + { + id: 'bfmeta', + version: '1.0', + chainKind: 'bioforest', + name: 'BFMeta', + symbol: 'BFM', + decimals: 8, + prefix: 'b', + enabled: true, + source: 'default', + }, + { + id: 'ethereum', + version: '1.0', + chainKind: 'evm', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + enabled: true, + source: 'default', + }, + { + id: 'binance', + version: '1.0', + chainKind: 'evm', + name: 'BSC', + symbol: 'BNB', + decimals: 18, + enabled: true, + source: 'default', + }, + { + id: 'tron', + version: '1.0', + chainKind: 'tron', + name: 'Tron', + symbol: 'TRX', + decimals: 6, + enabled: true, + source: 'default', + }, + { + id: 'bitcoin', + version: '1.0', + chainKind: 'bitcoin', + name: 'Bitcoin', + symbol: 'BTC', + decimals: 8, + enabled: true, + source: 'default', + }, +] + +describe('buildWalletChainAddresses', () => { + it('should return empty array when no chains selected', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: [], + chainConfigs: mockChainConfigs, + }) + expect(result).toEqual([]) + }) + + it('should derive Tron address when tron is selected', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['tron'], + chainConfigs: mockChainConfigs, + }) + + expect(result).toHaveLength(1) + expect(result[0]?.chainId).toBe('tron') + // Tron address format: starts with T, base58check, 34 chars + expect(result[0]?.address).toMatch(/^T[1-9A-HJ-NP-Za-km-z]{33}$/) + }) + + it('should derive same EVM address for multiple EVM chains', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['ethereum', 'binance'], + chainConfigs: mockChainConfigs, + }) + + expect(result).toHaveLength(2) + const ethAddress = result.find(r => r.chainId === 'ethereum')?.address + const bscAddress = result.find(r => r.chainId === 'binance')?.address + + // EVM chains share the same address + expect(ethAddress).toBe(bscAddress) + // EVM address format: 0x + 40 hex chars + expect(ethAddress).toMatch(/^0x[a-fA-F0-9]{40}$/) + }) + + it('should derive BioForest address', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['bfmeta'], + chainConfigs: mockChainConfigs, + }) + + expect(result).toHaveLength(1) + expect(result[0]?.chainId).toBe('bfmeta') + // BioForest address: prefix + base58check + expect(result[0]?.address).toMatch(/^b[1-9A-HJ-NP-Za-km-z]+$/) + }) + + it('should derive Bitcoin address', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['bitcoin'], + chainConfigs: mockChainConfigs, + }) + + expect(result).toHaveLength(1) + expect(result[0]?.chainId).toBe('bitcoin') + // Bitcoin P2PKH address: starts with 1 or 3 + expect(result[0]?.address).toMatch(/^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/) + }) + + it('should be deterministic (same input same output)', () => { + const params = { + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['tron', 'ethereum', 'bfmeta'], + chainConfigs: mockChainConfigs, + } + + const result1 = buildWalletChainAddresses(params) + const result2 = buildWalletChainAddresses(params) + + expect(result1).toEqual(result2) + }) + + it('should respect selectedChainIds order', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['tron', 'ethereum', 'bfmeta'], + chainConfigs: mockChainConfigs, + }) + + expect(result).toHaveLength(3) + expect(result[0]?.chainId).toBe('tron') + expect(result[1]?.chainId).toBe('ethereum') + expect(result[2]?.chainId).toBe('bfmeta') + }) + + it('should skip unknown chainIds', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['tron', 'unknown-chain'], + chainConfigs: mockChainConfigs, + }) + + expect(result).toHaveLength(1) + expect(result[0]?.chainId).toBe('tron') + }) + + it('should skip custom chainKind', () => { + const configsWithCustom: ChainConfig[] = [ + ...mockChainConfigs, + { + id: 'my-custom', + version: '1.0', + chainKind: 'custom', + name: 'My Custom', + symbol: 'CUSTOM', + decimals: 18, + enabled: true, + source: 'manual', + }, + ] + + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['my-custom', 'tron'], + chainConfigs: configsWithCustom, + }) + + // custom chain should be skipped + expect(result).toHaveLength(1) + expect(result[0]?.chainId).toBe('tron') + }) + + it('should handle all chain kinds together', () => { + const result = buildWalletChainAddresses({ + mnemonic: TEST_MNEMONIC, + selectedChainIds: ['bfmeta', 'ethereum', 'tron', 'bitcoin'], + chainConfigs: mockChainConfigs, + }) + + expect(result).toHaveLength(4) + expect(result.map(r => r.chainId)).toEqual(['bfmeta', 'ethereum', 'tron', 'bitcoin']) + + // Each address has the correct format + const bfmeta = result.find(r => r.chainId === 'bfmeta') + const ethereum = result.find(r => r.chainId === 'ethereum') + const tron = result.find(r => r.chainId === 'tron') + const bitcoin = result.find(r => r.chainId === 'bitcoin') + + expect(bfmeta?.address).toMatch(/^b[1-9A-HJ-NP-Za-km-z]+$/) + expect(ethereum?.address).toMatch(/^0x[a-fA-F0-9]{40}$/) + expect(tron?.address).toMatch(/^T[1-9A-HJ-NP-Za-km-z]{33}$/) + expect(bitcoin?.address).toMatch(/^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/) + }) +}) diff --git a/src/services/wallet/chain-derivation/build-wallet-chain-addresses.ts b/src/services/wallet/chain-derivation/build-wallet-chain-addresses.ts new file mode 100644 index 000000000..b46f3c29a --- /dev/null +++ b/src/services/wallet/chain-derivation/build-wallet-chain-addresses.ts @@ -0,0 +1,120 @@ +/** + * buildWalletChainAddresses + * + * 单一入口:从 mnemonic + 选中的 chainIds + chainConfigs 派生所有地址 + * 纯函数、离线可用、deterministic + */ + +import type { ChainConfig } from '@/services/chain-config' +import type { ChainAddress, BuildWalletChainAddressesParams } from './types' +import { KeyMaterialProvider } from './key-material-provider' +import { EvmStrategy, BitcoinStrategy, TronStrategy, BioforestStrategy } from './strategies' + +const evmStrategy = new EvmStrategy() +const bitcoinStrategy = new BitcoinStrategy() +const tronStrategy = new TronStrategy() +const bioforestStrategy = new BioforestStrategy() + +/** + * 从 mnemonic 派生钱包的所有链地址 + * + * @param params.mnemonic - 助记词 + * @param params.accountIndex - 账户索引(默认 0) + * @param params.selectedChainIds - 用户选中的链 ID 列表 + * @param params.chainConfigs - 链配置列表 + * @returns 去重、稳定排序的 ChainAddress 数组 + */ +export function buildWalletChainAddresses(params: BuildWalletChainAddressesParams): ChainAddress[] { + const { mnemonic, accountIndex = 0, selectedChainIds, chainConfigs } = params + + const selectedConfigsMap = new Map() + for (const config of chainConfigs) { + if (selectedChainIds.includes(config.id)) { + selectedConfigsMap.set(config.id, config) + } + } + + if (selectedConfigsMap.size === 0) { + return [] + } + + const keyProvider = new KeyMaterialProvider(mnemonic) + const addressByChainId = new Map() + + // 按 chainKind 分组处理 + const evmConfigs: ChainConfig[] = [] + const bitcoinConfigs: ChainConfig[] = [] + const tronConfigs: ChainConfig[] = [] + const bioforestConfigs: ChainConfig[] = [] + const customConfigs: ChainConfig[] = [] + + for (const config of selectedConfigsMap.values()) { + switch (config.chainKind) { + case 'evm': + evmConfigs.push(config) + break + case 'bitcoin': + bitcoinConfigs.push(config) + break + case 'tron': + tronConfigs.push(config) + break + case 'bioforest': + bioforestConfigs.push(config) + break + case 'custom': + customConfigs.push(config) + break + } + } + + // EVM:所有 EVM 链共享同一地址 + if (evmConfigs.length > 0) { + const keyMaterial = keyProvider.getEvmKeyMaterial(accountIndex) + const firstConfig = evmConfigs[0]! + const result = evmStrategy.derive({ keyMaterial, config: firstConfig, accountIndex }) + for (const config of evmConfigs) { + addressByChainId.set(config.id, result.address) + } + } + + // Bitcoin + if (bitcoinConfigs.length > 0) { + const keyMaterial = keyProvider.getBitcoinKeyMaterial(accountIndex) + for (const config of bitcoinConfigs) { + const result = bitcoinStrategy.derive({ keyMaterial, config, accountIndex }) + addressByChainId.set(config.id, result.address) + } + } + + // Tron + if (tronConfigs.length > 0) { + const keyMaterial = keyProvider.getTronKeyMaterial(accountIndex) + for (const config of tronConfigs) { + const result = tronStrategy.derive({ keyMaterial, config, accountIndex }) + addressByChainId.set(config.id, result.address) + } + } + + // BioForest:使用特殊派生方式(不使用 BIP44) + if (bioforestConfigs.length > 0) { + for (const config of bioforestConfigs) { + const result = bioforestStrategy.deriveBioforest({ mnemonic, config, accountIndex }) + addressByChainId.set(config.id, result.address) + } + } + + // Custom:跳过,UI 应提示不支持 + // customConfigs 不派生地址 + + // 按 selectedChainIds 的顺序输出,保证 deterministic + const results: ChainAddress[] = [] + for (const chainId of selectedChainIds) { + const address = addressByChainId.get(chainId) + if (address) { + results.push({ chainId, address }) + } + } + + return results +} diff --git a/src/services/wallet/chain-derivation/index.ts b/src/services/wallet/chain-derivation/index.ts new file mode 100644 index 000000000..b898674d8 --- /dev/null +++ b/src/services/wallet/chain-derivation/index.ts @@ -0,0 +1,10 @@ +/** + * Chain Derivation Module + * + * 链地址派生模块 - 统一入口 + */ + +export * from './types' +export { KeyMaterialProvider } from './key-material-provider' +export { buildWalletChainAddresses } from './build-wallet-chain-addresses' +export * from './strategies' diff --git a/src/services/wallet/chain-derivation/key-material-provider.ts b/src/services/wallet/chain-derivation/key-material-provider.ts new file mode 100644 index 000000000..be2d8b174 --- /dev/null +++ b/src/services/wallet/chain-derivation/key-material-provider.ts @@ -0,0 +1,83 @@ +/** + * KeyMaterialProvider + * + * 从 mnemonic 派生基础密钥材料,供各 Strategy 使用 + * 避免重复计算,提高效率 + */ + +import { HDKey } from '@scure/bip32' +import { mnemonicToSeedSync } from '@scure/bip39' +import type { KeyMaterial } from './types' + +const COIN_TYPES = { + ethereum: 60, + bitcoin: 0, + tron: 195, + bfmeta: 9999, // bioforest +} as const + +export type CoinType = keyof typeof COIN_TYPES + +export class KeyMaterialProvider { + private hdKey: HDKey + private cache = new Map() + + constructor(mnemonic: string, password?: string) { + const seed = mnemonicToSeedSync(mnemonic, password) + this.hdKey = HDKey.fromMasterSeed(seed) + } + + /** + * 获取指定 coinType 和 accountIndex 的密钥材料 + * 使用 BIP44 路径:m/44'/coinType'/accountIndex'/0/0 + */ + getKeyMaterial(coinType: CoinType, accountIndex = 0): KeyMaterial { + const cacheKey = `${coinType}:${accountIndex}` + const cached = this.cache.get(cacheKey) + if (cached) return cached + + const coinTypeNum = COIN_TYPES[coinType] + const path = `m/44'/${coinTypeNum}'/${accountIndex}'/0/0` + const childKey = this.hdKey.derive(path) + + if (!childKey.privateKey || !childKey.publicKey) { + throw new Error(`Key derivation failed for path: ${path}`) + } + + const material: KeyMaterial = { + privateKey: childKey.privateKey, + publicKey: childKey.publicKey, + } + + this.cache.set(cacheKey, material) + return material + } + + /** + * 获取 EVM 密钥材料(Ethereum/BSC/Polygon 等共用同一密钥) + */ + getEvmKeyMaterial(accountIndex = 0): KeyMaterial { + return this.getKeyMaterial('ethereum', accountIndex) + } + + /** + * 获取 Bitcoin 密钥材料 + */ + getBitcoinKeyMaterial(accountIndex = 0): KeyMaterial { + return this.getKeyMaterial('bitcoin', accountIndex) + } + + /** + * 获取 Tron 密钥材料 + */ + getTronKeyMaterial(accountIndex = 0): KeyMaterial { + return this.getKeyMaterial('tron', accountIndex) + } + + /** + * 获取 BioForest 密钥材料 + */ + getBioforestKeyMaterial(accountIndex = 0): KeyMaterial { + return this.getKeyMaterial('bfmeta', accountIndex) + } +} diff --git a/src/services/wallet/chain-derivation/strategies/bioforest-strategy.ts b/src/services/wallet/chain-derivation/strategies/bioforest-strategy.ts new file mode 100644 index 000000000..8f9cb0f1c --- /dev/null +++ b/src/services/wallet/chain-derivation/strategies/bioforest-strategy.ts @@ -0,0 +1,99 @@ +/** + * BioForest Derivation Strategy + * + * BioForest 链地址派生策略 + * 注意:BioForest 使用 Ed25519 + SHA256 seed,与 BIP44 不同 + */ + +import { sha256 } from '@noble/hashes/sha2.js' +import { ripemd160 } from '@noble/hashes/legacy.js' +import { ed25519 } from '@noble/curves/ed25519.js' +import type { ChainConfig } from '@/services/chain-config' +import type { DerivationStrategy, ChainAddress } from '../types' + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +function base58Encode(bytes: Uint8Array): string { + const digits: number[] = [0] + + for (const byte of bytes) { + let carry = byte + for (let i = 0; i < digits.length; i++) { + carry += digits[i]! << 8 + digits[i] = carry % 58 + carry = Math.floor(carry / 58) + } + while (carry > 0) { + digits.push(carry % 58) + carry = Math.floor(carry / 58) + } + } + + let result = '' + for (const byte of bytes) { + if (byte === 0) result += BASE58_ALPHABET[0]! + else break + } + + for (let i = digits.length - 1; i >= 0; i--) { + result += BASE58_ALPHABET[digits[i]!] + } + + return result +} + +function base58CheckEncode(payload: Uint8Array): string { + const checksum = sha256(sha256(payload)).slice(0, 4) + const full = new Uint8Array(payload.length + checksum.length) + full.set(payload, 0) + full.set(checksum, payload.length) + return base58Encode(full) +} + +export interface BioforestDerivationParams { + mnemonic: string + config: ChainConfig + accountIndex: number +} + +function deriveBioforestAddress(mnemonic: string, prefix = 'b'): string { + const encoder = new TextEncoder() + const secretBytes = encoder.encode(mnemonic) + const seed = sha256(secretBytes) + const publicKey = ed25519.getPublicKey(seed) + const hash160 = ripemd160(sha256(publicKey)) + return prefix + base58CheckEncode(hash160) +} + +export class BioforestStrategy implements DerivationStrategy { + readonly chainKind = 'bioforest' as const + + supports(config: ChainConfig): boolean { + return config.chainKind === 'bioforest' + } + + /** + * BioForest 使用不同的派生方式: + * - 不使用 BIP44 派生的 keyMaterial + * - 直接用 mnemonic 做 SHA256 生成 Ed25519 keypair + * + * 因此这个 strategy 需要特殊处理 + */ + derive(_params: { keyMaterial: { privateKey: Uint8Array; publicKey: Uint8Array }; config: ChainConfig; accountIndex: number }): ChainAddress { + throw new Error('BioForest uses special derivation. Use deriveBioforest() instead.') + } + + /** + * BioForest 专用派生方法 + */ + deriveBioforest(params: BioforestDerivationParams): ChainAddress { + const { mnemonic, config } = params + const prefix = config.prefix ?? 'b' + const address = deriveBioforestAddress(mnemonic, prefix) + + return { + chainId: config.id, + address, + } + } +} diff --git a/src/services/wallet/chain-derivation/strategies/bitcoin-strategy.ts b/src/services/wallet/chain-derivation/strategies/bitcoin-strategy.ts new file mode 100644 index 000000000..d35688cab --- /dev/null +++ b/src/services/wallet/chain-derivation/strategies/bitcoin-strategy.ts @@ -0,0 +1,72 @@ +/** + * Bitcoin Derivation Strategy + * + * Bitcoin 地址派生策略(P2PKH - Legacy) + */ + +import { sha256 } from '@noble/hashes/sha2.js' +import { ripemd160 } from '@noble/hashes/legacy.js' +import type { ChainConfig } from '@/services/chain-config' +import type { DerivationStrategy, DerivationParams, ChainAddress } from '../types' + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +function base58Encode(bytes: Uint8Array): string { + const digits = [0] + + for (const byte of bytes) { + let carry = byte + for (let i = 0; i < digits.length; i++) { + carry += digits[i]! << 8 + digits[i] = carry % 58 + carry = (carry / 58) | 0 + } + while (carry > 0) { + digits.push(carry % 58) + carry = (carry / 58) | 0 + } + } + + let result = '' + for (const byte of bytes) { + if (byte === 0) result += BASE58_ALPHABET[0]! + else break + } + + for (let i = digits.length - 1; i >= 0; i--) { + result += BASE58_ALPHABET[digits[i]!] + } + + return result +} + +function publicKeyToBitcoinAddress(publicKey: Uint8Array, network: 'mainnet' | 'testnet' = 'mainnet'): string { + const sha = sha256(publicKey) + const hash160 = ripemd160(sha) + + const prefix = network === 'mainnet' ? 0x00 : 0x6f + const prefixed = new Uint8Array([prefix, ...hash160]) + + const checksum = sha256(sha256(prefixed)).slice(0, 4) + const full = new Uint8Array([...prefixed, ...checksum]) + + return base58Encode(full) +} + +export class BitcoinStrategy implements DerivationStrategy { + readonly chainKind = 'bitcoin' as const + + supports(config: ChainConfig): boolean { + return config.chainKind === 'bitcoin' + } + + derive(params: DerivationParams): ChainAddress { + const { keyMaterial, config } = params + const address = publicKeyToBitcoinAddress(keyMaterial.publicKey) + + return { + chainId: config.id, + address, + } + } +} diff --git a/src/services/wallet/chain-derivation/strategies/evm-strategy.ts b/src/services/wallet/chain-derivation/strategies/evm-strategy.ts new file mode 100644 index 000000000..518cd33ae --- /dev/null +++ b/src/services/wallet/chain-derivation/strategies/evm-strategy.ts @@ -0,0 +1,55 @@ +/** + * EVM Derivation Strategy + * + * EVM 链(Ethereum/BSC/Polygon 等)地址派生策略 + */ + +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { bytesToHex } from '@noble/hashes/utils.js' +import type { ChainConfig } from '@/services/chain-config' +import type { DerivationStrategy, DerivationParams, ChainAddress } from '../types' + +function privateKeyToEvmAddress(privateKey: Uint8Array): string { + const uncompressedPubKey = secp256k1.getPublicKey(privateKey, false) + const pubKeyWithoutPrefix = uncompressedPubKey.slice(1) + const hash = keccak_256(pubKeyWithoutPrefix) + const address = hash.slice(-20) + return '0x' + bytesToHex(address) +} + +function toChecksumAddress(address: string): string { + const addr = address.toLowerCase().replace('0x', '') + const encoder = new TextEncoder() + const hash = bytesToHex(keccak_256(encoder.encode(addr))) + + let checksumAddress = '0x' + for (let i = 0; i < addr.length; i++) { + if (parseInt(hash[i]!, 16) >= 8) { + checksumAddress += addr[i]!.toUpperCase() + } else { + checksumAddress += addr[i]! + } + } + + return checksumAddress +} + +export class EvmStrategy implements DerivationStrategy { + readonly chainKind = 'evm' as const + + supports(config: ChainConfig): boolean { + return config.chainKind === 'evm' + } + + derive(params: DerivationParams): ChainAddress { + const { keyMaterial, config } = params + const rawAddress = privateKeyToEvmAddress(keyMaterial.privateKey) + const address = toChecksumAddress(rawAddress) + + return { + chainId: config.id, + address, + } + } +} diff --git a/src/services/wallet/chain-derivation/strategies/index.ts b/src/services/wallet/chain-derivation/strategies/index.ts new file mode 100644 index 000000000..b0a58af9f --- /dev/null +++ b/src/services/wallet/chain-derivation/strategies/index.ts @@ -0,0 +1,8 @@ +/** + * Derivation Strategies Index + */ + +export { EvmStrategy } from './evm-strategy' +export { BitcoinStrategy } from './bitcoin-strategy' +export { TronStrategy } from './tron-strategy' +export { BioforestStrategy } from './bioforest-strategy' diff --git a/src/services/wallet/chain-derivation/strategies/tron-strategy.ts b/src/services/wallet/chain-derivation/strategies/tron-strategy.ts new file mode 100644 index 000000000..0e1076e25 --- /dev/null +++ b/src/services/wallet/chain-derivation/strategies/tron-strategy.ts @@ -0,0 +1,79 @@ +/** + * Tron Derivation Strategy + * + * Tron 地址派生策略 + * 使用行业标准路径: m/44'/195'/accountIndex'/0/0 + * 兼容 TronLink、Trust Wallet、Ledger 等主流钱包 + */ + +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { sha256 } from '@noble/hashes/sha2.js' +import type { ChainConfig } from '@/services/chain-config' +import type { DerivationStrategy, DerivationParams, ChainAddress } from '../types' + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +function base58Encode(bytes: Uint8Array): string { + const digits: number[] = [0] + + for (const byte of bytes) { + let carry = byte + for (let i = 0; i < digits.length; i++) { + carry += digits[i]! << 8 + digits[i] = carry % 58 + carry = Math.floor(carry / 58) + } + while (carry > 0) { + digits.push(carry % 58) + carry = Math.floor(carry / 58) + } + } + + let result = '' + for (const byte of bytes) { + if (byte === 0) result += BASE58_ALPHABET[0]! + else break + } + + for (let i = digits.length - 1; i >= 0; i--) { + result += BASE58_ALPHABET[digits[i]!] + } + + return result +} + +function privateKeyToTronAddress(privateKey: Uint8Array): string { + const uncompressedPubKey = secp256k1.getPublicKey(privateKey, false) + const pubKeyWithoutPrefix = uncompressedPubKey.slice(1) + + const hash = keccak_256(pubKeyWithoutPrefix) + const addressBytes = hash.slice(-20) + + // Tron 主网前缀 0x41 + const prefixed = new Uint8Array([0x41, ...addressBytes]) + + // Double SHA256 checksum + const checksum = sha256(sha256(prefixed)).slice(0, 4) + const full = new Uint8Array([...prefixed, ...checksum]) + + return base58Encode(full) +} + +export class TronStrategy implements DerivationStrategy { + readonly chainKind = 'tron' as const + + supports(config: ChainConfig): boolean { + return config.chainKind === 'tron' + } + + derive(params: DerivationParams): ChainAddress { + const { keyMaterial, config } = params + const address = privateKeyToTronAddress(keyMaterial.privateKey) + + return { + chainId: config.id, + address, + } + } +} diff --git a/src/services/wallet/chain-derivation/types.ts b/src/services/wallet/chain-derivation/types.ts new file mode 100644 index 000000000..6c664a39e --- /dev/null +++ b/src/services/wallet/chain-derivation/types.ts @@ -0,0 +1,36 @@ +/** + * Chain Derivation Types + * + * 链地址派生模块的类型定义 + */ + +import type { ChainConfig, ChainKind } from '@/services/chain-config' + +export interface ChainAddress { + chainId: string + address: string +} + +export interface KeyMaterial { + privateKey: Uint8Array + publicKey: Uint8Array +} + +export interface DerivationParams { + keyMaterial: KeyMaterial + config: ChainConfig + accountIndex: number +} + +export interface DerivationStrategy { + chainKind: ChainKind + supports(config: ChainConfig): boolean + derive(params: DerivationParams): ChainAddress +} + +export interface BuildWalletChainAddressesParams { + mnemonic: string + accountIndex?: number + selectedChainIds: string[] + chainConfigs: ChainConfig[] +} diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index e2a73201c..361e1c312 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -169,7 +169,7 @@ export function WalletTab() { } return ( -
+
{/* 钱包卡片轮播 */}