diff --git "a/docs/white-book/10-\347\224\237\346\200\201\347\257\207/02-BioSDK\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/white-book/10-\347\224\237\346\200\201\347\257\207/02-BioSDK\345\274\200\345\217\221\346\214\207\345\215\227.md" index 9dfc247d..9f9e5877 100644 --- "a/docs/white-book/10-\347\224\237\346\200\201\347\257\207/02-BioSDK\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/white-book/10-\347\224\237\346\200\201\347\257\207/02-BioSDK\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -74,13 +74,21 @@ const accounts = await window.bio.request({ #### bio_selectAccount -显示账户选择器。 +显示账户选择器。返回的 `BioAccount` 包含公钥。 ```typescript +type BioAccount = { + address: string + chain: string + name?: string + publicKey: string // 公钥 (hex) +} + const account = await window.bio.request({ method: 'bio_selectAccount', params: [{ chain: 'ethereum' }] // 可选:限制链类型 }) +// account.publicKey - 可用于后端验证签名 ``` #### bio_pickWallet @@ -101,16 +109,23 @@ const target = await window.bio.request({ #### bio_signMessage -签名消息。 +签名消息。返回签名和公钥(hex 格式)。 ```typescript -const signature = await window.bio.request({ +type BioSignMessageResult = { + signature: string // 签名 hex + publicKey: string // 公钥 hex +} + +const result = await window.bio.request({ method: 'bio_signMessage', params: [{ message: 'Hello, Bio!', address: '...' }] }) +// result.signature - 签名数据 +// result.publicKey - 公钥(用于后端验证) ``` ### 转账 diff --git a/miniapps/forge/.storybook/vitest.setup.ts b/miniapps/forge/.storybook/vitest.setup.ts index 73b80607..27c1a049 100644 --- a/miniapps/forge/.storybook/vitest.setup.ts +++ b/miniapps/forge/.storybook/vitest.setup.ts @@ -1,7 +1,10 @@ -import { beforeAll } from 'vitest' +import { beforeAll, vi } from 'vitest' import { setProjectAnnotations } from '@storybook/react-vite' import * as previewAnnotations from './preview' +// Mock environment variable for Storybook tests +vi.stubEnv('VITE_COT_API_BASE_URL', 'http://mock-api.local') + const annotations = setProjectAnnotations([previewAnnotations]) beforeAll(annotations.beforeAll) diff --git a/miniapps/forge/e2e/helpers/i18n.ts b/miniapps/forge/e2e/helpers/i18n.ts index c4ee6f31..ae0ba894 100644 --- a/miniapps/forge/e2e/helpers/i18n.ts +++ b/miniapps/forge/e2e/helpers/i18n.ts @@ -12,6 +12,7 @@ export const UI_TEXT = { connect: { button: { source: '连接钱包', pattern: /连接钱包|Connect Wallet/i }, loading: { source: '连接中', pattern: /连接中|Connecting/i }, + configError: { source: 'Network Error', pattern: /Network Error|Failed to load config/i }, }, swap: { pay: { source: '支付', pattern: /支付|Pay/i }, diff --git a/miniapps/forge/e2e/ui.spec.ts b/miniapps/forge/e2e/ui.spec.ts index c8ce0659..db8486ec 100644 --- a/miniapps/forge/e2e/ui.spec.ts +++ b/miniapps/forge/e2e/ui.spec.ts @@ -246,4 +246,110 @@ test.describe('Forge UI', () => { await expect(page.locator('button:has-text("BNB")').first()).toBeVisible({ timeout: 5000 }) } }) + + // ============ 边界场景测试 ============ + + test('10 - API failure - config unavailable', async ({ page }) => { + // Override API mock to throw network error + await page.addInitScript(` + const originalFetch = window.fetch + window.fetch = async (url, options) => { + const urlStr = typeof url === 'string' ? url : url.toString() + if (urlStr.includes('/cot/recharge/support') || urlStr.includes('/recharge/support')) { + throw new Error('Network Error') + } + return originalFetch(url, options) + } + `) + await page.addInitScript(mockBioSDK) + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Connect button should be visible + const connectBtn = page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first() + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + + // Error message should be visible (config load failed) + await expect(page.locator(`text=${UI_TEXT.connect.configError.source}`)).toBeVisible({ timeout: 5000 }) + }) + + test('11 - user rejects wallet connection', async ({ page }) => { + // Mock bio SDK that rejects wallet selection + await page.addInitScript(` + window.bio = { + request: async ({ method }) => { + if (method === 'bio_closeSplashScreen') return {} + if (method === 'bio_selectAccount') { + throw new Error('用户取消') + } + return {} + } + } + `) + await page.goto('/') + await page.waitForLoadState('networkidle') + + const connectBtn = page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first() + await expect(connectBtn).toBeVisible({ timeout: 10000 }) + await connectBtn.click() + + // Should show user cancelled error + await expect(page.locator('text=用户取消')).toBeVisible({ timeout: 5000 }) + }) + + test('12 - recharge API failure', async ({ page }) => { + // Mock API that fails on recharge + await page.addInitScript(` + const originalFetch = window.fetch + window.fetch = async (url, options) => { + const urlStr = typeof url === 'string' ? url : url.toString() + + if (urlStr.includes('/cot/recharge/support') || urlStr.includes('/recharge/support')) { + return { + ok: true, + json: () => Promise.resolve({ + recharge: { + bfmeta: { + BFM: { + enable: true, + logo: '', + supportChain: { + ETH: { enable: true, assetType: 'ETH', depositAddress: '0x1234567890', logo: '' }, + }, + }, + }, + }, + }), + } + } + + // Fail on recharge POST + if (urlStr.includes('/cot/recharge/V2') || urlStr.includes('/recharge/V2')) { + throw new Error('服务器错误') + } + + return originalFetch(url, options) + } + `) + await page.addInitScript(mockBioSDK) + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate through flow + await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click() + await page.waitForSelector('input[type="number"]', { timeout: 10000 }) + + // Enter amount + await page.fill('input[type="number"]', '0.5') + + // Click preview + await page.locator(`button:has-text("${UI_TEXT.swap.preview.source}")`).click() + await expect(page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first()).toBeVisible({ timeout: 5000 }) + + // Confirm - this should eventually fail + await page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first().click() + + // Should show error (may take time due to signing flow) + await expect(page.getByText(/锻造失败|服务器错误/).first()).toBeVisible({ timeout: 15000 }) + }) }) diff --git a/miniapps/forge/src/App.tsx b/miniapps/forge/src/App.tsx index 38097037..0a4d7db6 100644 --- a/miniapps/forge/src/App.tsx +++ b/miniapps/forge/src/App.tsx @@ -240,6 +240,7 @@ export default function App() { )} setAmount(e.target.value)} @@ -344,6 +346,7 @@ export default function App() {
@@ -468,6 +476,7 @@ export default function App() { diff --git a/miniapps/teleport/src/api/types.ts b/miniapps/teleport/src/api/types.ts index 67d60644..8de5c7b7 100644 --- a/miniapps/teleport/src/api/types.ts +++ b/miniapps/teleport/src/api/types.ts @@ -57,8 +57,10 @@ export interface TransmitAssetTypeListResponse { export interface ExternalFromTrJson { eth?: { signTransData: string } bsc?: { signTransData: string } - tron?: unknown - trc20?: unknown + /** TRON TRX 转账的签名交易数据 */ + tron?: { signTransData: string } + /** TRON TRC20 代币转账的签名交易数据 */ + trc20?: { signTransData: string } } // 内链发起方交易体 diff --git a/packages/bio-sdk/src/types.ts b/packages/bio-sdk/src/types.ts index 8c9ceaed..9d8d5167 100644 --- a/packages/bio-sdk/src/types.ts +++ b/packages/bio-sdk/src/types.ts @@ -8,6 +8,8 @@ export interface BioAccount { address: string chain: string name?: string + /** Public key (hex encoded) */ + publicKey: string } /** Transfer parameters */ @@ -80,9 +82,6 @@ export interface BioMethods { /** Pick another wallet address (shows wallet picker UI) */ bio_pickWallet: (opts?: { chain?: string; exclude?: string }) => Promise - /** Get public key for an address (hex encoded) */ - bio_getPublicKey: (params: { address: string }) => Promise - /** Sign a message, returns signature and public key (hex) */ bio_signMessage: (params: { message: string; address: string }) => Promise<{ signature: string; publicKey: string }> diff --git a/src/lib/crypto/address-derivation.ts b/src/lib/crypto/address-derivation.ts index 6274956c..f83adf47 100644 --- a/src/lib/crypto/address-derivation.ts +++ b/src/lib/crypto/address-derivation.ts @@ -14,6 +14,8 @@ import { deriveKey, deriveBitcoinKey, type BitcoinPurpose } from './derivation' export interface DerivedAddress { chainId: string address: string + /** 公钥 (hex) */ + publicKey: string } /** @@ -47,17 +49,19 @@ export function deriveAddressesForChains( results.push({ chainId: chain.id, address: evmKey.address, + publicKey: evmKey.publicKey, }) } } // Derive BIP39 addresses (chain-specific derivation paths) for (const chain of bip39Chains) { - const address = deriveBip39Address(secret, chain) - if (address) { + const derived = deriveBip39Key(secret, chain) + if (derived) { results.push({ chainId: chain.id, - address, + address: derived.address, + publicKey: derived.publicKey, }) } } @@ -66,18 +70,18 @@ export function deriveAddressesForChains( } /** - * Derive address for a BIP39-based chain + * Derive key for a BIP39-based chain */ -function deriveBip39Address(secret: string, chain: ChainConfig): string | null { +function deriveBip39Key(secret: string, chain: ChainConfig): { address: string; publicKey: string } | null { switch (chain.id) { case 'bitcoin': { // Use Native SegWit (BIP84) as default for Bitcoin const btcKey = deriveBitcoinKey(secret, 84 as BitcoinPurpose, 0, 0) - return btcKey.address + return { address: btcKey.address, publicKey: btcKey.publicKey } } case 'tron': { const tronKey = deriveKey(secret, 'tron', 0, 0) - return tronKey.address + return { address: tronKey.address, publicKey: tronKey.publicKey } } default: // Unknown BIP39 chain - skip diff --git a/src/lib/crypto/bioforest.ts b/src/lib/crypto/bioforest.ts index 504b7ab4..329eaf28 100644 --- a/src/lib/crypto/bioforest.ts +++ b/src/lib/crypto/bioforest.ts @@ -129,12 +129,14 @@ export function deriveBioforestKeyFromChainConfig(secret: string, config: ChainC export function deriveBioforestAddressesFromChainConfigs( secret: string, configs: readonly ChainConfig[], -): Array<{ chainId: string; address: string }> { +): Array<{ chainId: string; address: string; publicKey: string }> { const keypair = createBioforestKeypair(secret) + const publicKeyHex = bytesToHex(keypair.publicKey) return configs.filter(isBioforestChainConfig).map((config) => ({ chainId: config.id, address: publicKeyToBioforestAddress(keypair.publicKey, config.prefix ?? 'b'), + publicKey: publicKeyHex, })) } diff --git a/src/pages/ecosystem/miniapp.tsx b/src/pages/ecosystem/miniapp.tsx index 12eecc54..37a86ac8 100644 --- a/src/pages/ecosystem/miniapp.tsx +++ b/src/pages/ecosystem/miniapp.tsx @@ -105,7 +105,7 @@ export function MiniappPage({ appId, onClose }: MiniappPageProps) { // 钱包选择器 const showWalletPicker = useCallback( - (opts?: { chain?: string }): Promise => { + (opts?: { chain?: string; exclude?: string }): Promise => { return new Promise((resolve) => { const handleSelect = (e: Event) => { const detail = (e as CustomEvent).detail @@ -129,6 +129,7 @@ export function MiniappPage({ appId, onClose }: MiniappPageProps) { const params: Record = {} if (opts?.chain) params.chain = opts.chain + if (opts?.exclude) params.exclude = opts.exclude if (app?.name) params.appName = app.name if (app?.icon) params.appIcon = app.icon push('WalletPickerJob', params) diff --git a/src/services/ecosystem/handlers/index.ts b/src/services/ecosystem/handlers/index.ts index 4f81684e..c136076c 100644 --- a/src/services/ecosystem/handlers/index.ts +++ b/src/services/ecosystem/handlers/index.ts @@ -12,7 +12,6 @@ export { handlePickWallet, handleChainId, handleGetBalance, - handleGetPublicKey, setWalletPicker, setGetAccounts, } from './wallet' diff --git a/src/services/ecosystem/handlers/wallet.ts b/src/services/ecosystem/handlers/wallet.ts index 9996140e..6ebc2ace 100644 --- a/src/services/ecosystem/handlers/wallet.ts +++ b/src/services/ecosystem/handlers/wallet.ts @@ -109,31 +109,3 @@ export const handleGetBalance: MethodHandler = async (params, _context) => { // TODO: Query actual balance from chain adapter return '0' } - -/** bio_getPublicKey - Get public key for an address */ -export const handleGetPublicKey: MethodHandler = async (params, context) => { - const opts = params as { address?: string } | undefined - if (!opts?.address) { - throw Object.assign(new Error('Missing address'), { code: BioErrorCodes.INVALID_PARAMS }) - } - - const getConnectedAccounts = getAccountsGetter(context.appId) - if (!getConnectedAccounts) { - throw Object.assign(new Error('Not connected'), { code: BioErrorCodes.UNAUTHORIZED }) - } - - const accounts = getConnectedAccounts() - const account = accounts.find(a => a.address.toLowerCase() === opts.address!.toLowerCase()) - if (!account) { - throw Object.assign(new Error('Address not found in connected accounts'), { code: BioErrorCodes.UNAUTHORIZED }) - } - - // TODO: Retrieve actual public key from wallet store - // For BioForest chains, the public key can be derived from the mnemonic - // For now, this returns a placeholder - needs to be implemented with wallet integration - // The public key format should be confirmed with the backend (hex, base58, etc.) - throw Object.assign( - new Error('bio_getPublicKey not yet implemented - requires wallet store integration'), - { code: BioErrorCodes.UNSUPPORTED_METHOD } - ) -} diff --git a/src/services/ecosystem/provider.ts b/src/services/ecosystem/provider.ts index 15c8ff5d..f9dc80af 100644 --- a/src/services/ecosystem/provider.ts +++ b/src/services/ecosystem/provider.ts @@ -13,7 +13,6 @@ import { handlePickWallet, handleChainId, handleGetBalance, - handleGetPublicKey, handleSignMessage, handleSignTypedData, handleCreateTransaction, @@ -31,7 +30,6 @@ export function initBioProvider(): void { bridge.registerHandler('bio_pickWallet', handlePickWallet) bridge.registerHandler('bio_chainId', handleChainId) bridge.registerHandler('bio_getBalance', handleGetBalance) - bridge.registerHandler('bio_getPublicKey', handleGetPublicKey) // Signing methods bridge.registerHandler('bio_signMessage', handleSignMessage) @@ -52,7 +50,6 @@ export function initBioProvider(): void { 'bio_pickWallet', 'bio_chainId', 'bio_getBalance', - 'bio_getPublicKey', 'bio_signMessage', 'bio_signTypedData', 'bio_createTransaction', diff --git a/src/stackflow/activities/sheets/WalletPickerJob.tsx b/src/stackflow/activities/sheets/WalletPickerJob.tsx index 94282056..14354c0e 100644 --- a/src/stackflow/activities/sheets/WalletPickerJob.tsx +++ b/src/stackflow/activities/sheets/WalletPickerJob.tsx @@ -18,6 +18,8 @@ import { MiniappIcon } from '@/components/ecosystem' type WalletPickerJobParams = { /** 限定链类型 */ chain?: string + /** 排除的地址(不显示在列表中) */ + exclude?: string /** 请求来源小程序名称 */ appName?: string /** 请求来源小程序图标 */ @@ -27,13 +29,14 @@ type WalletPickerJobParams = { function WalletPickerJobContent() { const { t } = useTranslation('common') const { pop } = useFlow() - const { chain, appName, appIcon } = useActivityParams() + const { chain, exclude, appName, appIcon } = useActivityParams() const walletState = useStore(walletStore) const currentWallet = walletSelectors.getCurrentWallet(walletState) - // 转换钱包数据为 WalletListItem 格式 + // 转换钱包数据为 WalletListItem 格式,并过滤排除的地址 const walletItems = useMemo((): WalletListItem[] => { + const excludeLower = exclude?.toLowerCase() return walletState.wallets .map((wallet) => { const chainAddress = chain @@ -42,6 +45,11 @@ function WalletPickerJobContent() { if (!chainAddress) return null + // 过滤排除的地址 + if (excludeLower && chainAddress.address.toLowerCase() === excludeLower) { + return null + } + return { id: wallet.id, name: wallet.name, @@ -51,7 +59,7 @@ function WalletPickerJobContent() { } }) .filter((item): item is WalletListItem => item !== null) - }, [walletState.wallets, chain]) + }, [walletState.wallets, chain, exclude]) // 保存钱包到链地址的映射 const walletChainMap = useMemo(() => { @@ -76,6 +84,7 @@ function WalletPickerJobContent() { address: data.chainAddress.address, chain: data.chainAddress.chain, name: data.wallet.name, + publicKey: data.chainAddress.publicKey, }, }) window.dispatchEvent(event) diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index 02a45dcb..a4d724b0 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -48,6 +48,8 @@ export interface Token { export interface ChainAddress { chain: ChainType address: string + /** 公钥(hex 编码) */ + publicKey: string /** 该链上的代币 */ tokens: Token[] } @@ -143,6 +145,7 @@ function walletInfoToWallet(info: WalletInfo, chainAddresses: ChainAddressInfo[] chainAddresses: chainAddresses.map((ca): ChainAddress => ({ chain: ca.chain, address: ca.address, + publicKey: ca.publicKey ?? '', tokens: ca.assets.map((asset): Token => { const token: Token = { id: `${ca.chain}:${asset.assetType}`, @@ -263,7 +266,7 @@ export const walletActions = { // 保存链地址 const chainAddresses = wallet.chainAddresses || [ - { chain: wallet.chain, address: wallet.address, tokens: [] } + { chain: wallet.chain, address: wallet.address, publicKey: '', tokens: [] } ] for (const ca of chainAddresses) { @@ -272,6 +275,7 @@ export const walletActions = { walletId, chain: ca.chain, address: ca.address, + publicKey: ca.publicKey, assets: [], isCustomAssets: false, isFrozen: false, @@ -608,7 +612,7 @@ export const walletActions = { } // 如果有需要添加的链,派生地址 - let newAddresses: Array<{ chain: string; address: string }> = [] + let newAddresses: Array<{ chain: string; address: string; publicKey: string }> = [] if (chainsToAdd.length > 0) { // 动态导入避免循环依赖 const { deriveAddressesForChains } = await import('@/lib/crypto/address-derivation') @@ -616,15 +620,15 @@ export const walletActions = { // 只派生需要添加的链 const chainsToDerive = chainConfigs.filter((c) => chainsToAdd.includes(c.id)) const derivedAddresses = deriveAddressesForChains(mnemonic, chainsToDerive) - const addressMap = new Map(derivedAddresses.map((a) => [a.chainId, a.address])) + const addressMap = new Map(derivedAddresses.map((a) => [a.chainId, { address: a.address, publicKey: a.publicKey }])) newAddresses = chainsToAdd .map((chainId) => { - const address = addressMap.get(chainId) - if (!address) return null - return { chain: chainId, address } + const derived = addressMap.get(chainId) + if (!derived) return null + return { chain: chainId, address: derived.address, publicKey: derived.publicKey } }) - .filter((a): a is { chain: string; address: string } => a !== null) + .filter((a): a is { chain: string; address: string; publicKey: string } => a !== null) } // 更新 IndexedDB - 添加新链地址 @@ -634,6 +638,7 @@ export const walletActions = { walletId, chain: addr.chain, address: addr.address, + publicKey: addr.publicKey, assets: [], isCustomAssets: false, isFrozen: false, @@ -653,6 +658,7 @@ export const walletActions = { ...newAddresses.map((addr) => ({ chain: addr.chain, address: addr.address, + publicKey: addr.publicKey, tokens: [], })), ] @@ -693,7 +699,7 @@ export const walletActions = { chain: wallet.chain, createdAt: wallet.createdAt ?? Date.now(), chainAddresses: wallet.chainAddresses ?? [ - { chain: wallet.chain, address: wallet.address, tokens: [] } + { chain: wallet.chain, address: wallet.address, publicKey: '', tokens: [] } ], themeHue: wallet.themeHue, tokens: [],