+
Scanner Validator 测试
+
+
+
+
+
+
+
+
+ setInput(e.target.value)}
+ className="w-full rounded border px-3 py-2 font-mono text-sm"
+ placeholder="输入地址或二维码内容"
+ />
+
+
+
+
+
+ {JSON.stringify(parsed, null, 2)}
+
+
+
+
+ {result === true ? (
+
✓ 验证通过
+ ) : (
+
✗ {result}
+ )}
+
+
+ )
+ },
+}
+
+/** 地址格式测试 */
+export const AddressFormats: StoryObj = {
+ render: () => {
+ const testCases = [
+ { label: 'ETH 地址', value: '0x742d35Cc6634C0532925a3b844Bc9e7595f12345' },
+ { label: 'ETH URI', value: 'ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f12345' },
+ { label: 'ETH 支付', value: 'ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f12345?value=1000000000000000000' },
+ { label: 'BTC 地址', value: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' },
+ { label: 'BTC URI', value: 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' },
+ { label: 'TRX 地址', value: 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW' },
+ { label: '无效地址', value: 'hello world' },
+ { label: '短地址', value: '0x123' },
+ ]
+
+ return (
+
+ {/* Handle */}
+
+
+ {/* Header */}
+
+
+
扫一扫
+
+
+
+ {/* Camera View */}
+
+ {/* Simulated camera */}
+
+
+ {/* Scan overlay */}
+
+
+ {/* Message area */}
+
+ {message ? (
+
+ {message.text}
+
+ ) : (
+
请提供 Ethereum 链的地址二维码
+ )}
+
+
+
+ {/* Bottom controls */}
+
+
+ {/* Test buttons */}
+
+
+
+
+
+
+ )
+ },
+}
diff --git a/src/stackflow/activities/sheets/__tests__/ScannerJob.test.ts b/src/stackflow/activities/sheets/__tests__/ScannerJob.test.ts
new file mode 100644
index 00000000..003fa360
--- /dev/null
+++ b/src/stackflow/activities/sheets/__tests__/ScannerJob.test.ts
@@ -0,0 +1,233 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import {
+ scanValidators,
+ getValidatorForChain,
+ setScannerResultCallback,
+} from '../scanner-validators'
+import { parseQRContent } from '@/lib/qr-parser'
+
+describe('ScannerJob validators', () => {
+ describe('ethereumAddress', () => {
+ it('accepts valid Ethereum address', () => {
+ const content = '0x742d35Cc6634C0532925a3b844Bc9e7595f12345'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe(true)
+ })
+
+ it('accepts ethereum: URI', () => {
+ const content = 'ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f12345'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe(true)
+ })
+
+ it('rejects Bitcoin address', () => {
+ const content = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe('invalidEthereumAddress')
+ })
+
+ it('rejects random text', () => {
+ const content = 'hello world'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe('invalidEthereumAddress')
+ })
+ })
+
+ describe('bitcoinAddress', () => {
+ it('accepts bitcoin: URI', () => {
+ const content = 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.bitcoinAddress(content, parsed)).toBe(true)
+ })
+
+ it('rejects Ethereum address', () => {
+ const content = '0x742d35Cc6634C0532925a3b844Bc9e7595f12345'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.bitcoinAddress(content, parsed)).toBe('invalidBitcoinAddress')
+ })
+ })
+
+ describe('tronAddress', () => {
+ it('accepts valid Tron address', () => {
+ const content = 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.tronAddress(content, parsed)).toBe(true)
+ })
+
+ it('rejects Ethereum address', () => {
+ const content = '0x742d35Cc6634C0532925a3b844Bc9e7595f12345'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.tronAddress(content, parsed)).toBe('invalidTronAddress')
+ })
+ })
+
+ describe('anyAddress', () => {
+ it('accepts Ethereum address', () => {
+ const content = '0x742d35Cc6634C0532925a3b844Bc9e7595f12345'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.anyAddress(content, parsed)).toBe(true)
+ })
+
+ it('accepts bitcoin: URI', () => {
+ const content = 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.anyAddress(content, parsed)).toBe(true)
+ })
+
+ it('accepts Tron address', () => {
+ const content = 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.anyAddress(content, parsed)).toBe(true)
+ })
+
+ it('rejects short text', () => {
+ const content = 'hello'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.anyAddress(content, parsed)).toBe('invalidAddress')
+ })
+ })
+
+ describe('any', () => {
+ it('accepts any content', () => {
+ expect(scanValidators.any()).toBe(true)
+ })
+ })
+})
+
+describe('getValidatorForChain', () => {
+ it('returns ethereumAddress for ethereum', () => {
+ expect(getValidatorForChain('ethereum')).toBe(scanValidators.ethereumAddress)
+ expect(getValidatorForChain('eth')).toBe(scanValidators.ethereumAddress)
+ expect(getValidatorForChain('ETHEREUM')).toBe(scanValidators.ethereumAddress)
+ })
+
+ it('returns bitcoinAddress for bitcoin', () => {
+ expect(getValidatorForChain('bitcoin')).toBe(scanValidators.bitcoinAddress)
+ expect(getValidatorForChain('btc')).toBe(scanValidators.bitcoinAddress)
+ })
+
+ it('returns tronAddress for tron', () => {
+ expect(getValidatorForChain('tron')).toBe(scanValidators.tronAddress)
+ expect(getValidatorForChain('trx')).toBe(scanValidators.tronAddress)
+ })
+
+ it('returns anyAddress for unknown chain', () => {
+ expect(getValidatorForChain('unknown')).toBe(scanValidators.anyAddress)
+ expect(getValidatorForChain(undefined)).toBe(scanValidators.anyAddress)
+ })
+})
+
+describe('setScannerResultCallback', () => {
+ beforeEach(() => {
+ // Reset before each test
+ })
+
+ afterEach(() => {
+ setScannerResultCallback(null)
+ })
+
+ it('sets callback that can be invoked', () => {
+ const callback = vi.fn()
+
+ setScannerResultCallback(callback)
+
+ // Note: In real usage, the callback is invoked by handleScanResult inside ScannerJobContent
+ // Here we just verify the callback is set correctly
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('can clear callback with null', () => {
+ const callback = vi.fn()
+ setScannerResultCallback(callback)
+ setScannerResultCallback(null)
+ // Callback is cleared
+ })
+})
+
+describe('Address validation edge cases', () => {
+ it('validates Ethereum address with checksum', () => {
+ // Mixed case (checksum)
+ const content = '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe(true)
+ })
+
+ it('validates Ethereum address lowercase', () => {
+ const content = '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe(true)
+ })
+
+ it('rejects Ethereum address with wrong length', () => {
+ const content = '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1Be' // Too short
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe('invalidEthereumAddress')
+ })
+
+ it('validates ethereum payment URI with amount', () => {
+ const content = 'ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f12345?value=1000000000000000000'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe(true)
+ })
+
+ it('rejects Ethereum address with invalid characters', () => {
+ const content = '0xGGGG35Cc6634C0532925a3b844Bc9e7595f12345'
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe('invalidEthereumAddress')
+ })
+
+ it('rejects empty string', () => {
+ const content = ''
+ const parsed = parseQRContent(content)
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe('invalidEthereumAddress')
+ expect(scanValidators.bitcoinAddress(content, parsed)).toBe('invalidBitcoinAddress')
+ expect(scanValidators.tronAddress(content, parsed)).toBe('invalidTronAddress')
+ expect(scanValidators.anyAddress(content, parsed)).toBe('invalidAddress')
+ })
+
+ it('rejects whitespace only', () => {
+ const content = ' '
+ const parsed = parseQRContent(content)
+ expect(scanValidators.anyAddress(content, parsed)).toBe('invalidAddress')
+ })
+
+ it('validates Tron address with exact length', () => {
+ // Tron addresses are T + 33 chars = 34 total
+ const content = 'TJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW'
+ expect(content.length).toBe(34)
+ const parsed = parseQRContent(content)
+ expect(scanValidators.tronAddress(content, parsed)).toBe(true)
+ })
+
+ it('rejects Tron address with wrong prefix', () => {
+ const content = 'BJCnKsPa7y5okkXvQAidZBzqx3QyQ6sxMW' // B instead of T
+ const parsed = parseQRContent(content)
+ expect(scanValidators.tronAddress(content, parsed)).toBe('invalidTronAddress')
+ })
+
+ it('anyAddress accepts address-like strings within length range', () => {
+ // Between 26-64 characters
+ const content26 = 'abcdefghijklmnopqrstuvwxyz' // 26 chars
+ const content64 = 'a'.repeat(64)
+ const parsed26 = parseQRContent(content26)
+ const parsed64 = parseQRContent(content64)
+ expect(scanValidators.anyAddress(content26, parsed26)).toBe(true)
+ expect(scanValidators.anyAddress(content64, parsed64)).toBe(true)
+ })
+
+ it('anyAddress rejects strings too short or too long', () => {
+ const content25 = 'a'.repeat(25) // Too short
+ const content65 = 'a'.repeat(65) // Too long
+ const parsed25 = parseQRContent(content25)
+ const parsed65 = parseQRContent(content65)
+ expect(scanValidators.anyAddress(content25, parsed25)).toBe('invalidAddress')
+ expect(scanValidators.anyAddress(content65, parsed65)).toBe('invalidAddress')
+ })
+
+ it('rejects contact type for address validators', () => {
+ const content = '{"type":"contact","name":"Test","addresses":[{"chainType":"ethereum","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f12345"}]}'
+ const parsed = parseQRContent(content)
+ expect(parsed.type).toBe('contact')
+ expect(scanValidators.ethereumAddress(content, parsed)).toBe('invalidEthereumAddress')
+ })
+})
diff --git a/src/stackflow/activities/sheets/index.ts b/src/stackflow/activities/sheets/index.ts
index 373707e6..fca52e55 100644
--- a/src/stackflow/activities/sheets/index.ts
+++ b/src/stackflow/activities/sheets/index.ts
@@ -12,3 +12,6 @@ export { SecurityWarningJob, setSecurityWarningConfirmCallback } from "./Securit
export { TransferConfirmJob, setTransferConfirmCallback } from "./TransferConfirmJob";
export { TransferWalletLockJob, setTransferWalletLockCallback } from "./TransferWalletLockJob";
export { FeeEditJob, setFeeEditCallback, type FeeEditConfig, type FeeEditResult } from "./FeeEditJob";
+export { ScannerJob, setScannerResultCallback, scanValidators, getValidatorForChain, type ScannerJobParams, type ScannerResultEvent, type ScanValidator } from "./ScannerJob";
+export { ContactAddConfirmJob, type ContactAddConfirmJobParams } from "./ContactAddConfirmJob";
+export { ContactShareJob, type ContactShareJobParams } from "./ContactShareJob";
diff --git a/src/stackflow/activities/sheets/scanner-validators.ts b/src/stackflow/activities/sheets/scanner-validators.ts
new file mode 100644
index 00000000..98d83409
--- /dev/null
+++ b/src/stackflow/activities/sheets/scanner-validators.ts
@@ -0,0 +1,74 @@
+/**
+ * ScannerJob validators - 扫码验证器
+ */
+
+import type { ParsedQRContent } from '@/lib/qr-parser'
+
+/** 验证器类型 */
+export type ScanValidator = (content: string, parsed: ParsedQRContent) => true | string
+
+/** 预设验证器 */
+export const scanValidators = {
+ ethereumAddress: (_content: string, parsed: ParsedQRContent): true | string => {
+ if (parsed.type === 'address' && parsed.chain === 'ethereum') return true
+ if (parsed.type === 'payment' && parsed.chain === 'ethereum') return true
+ if (parsed.type === 'unknown' && /^0x[a-fA-F0-9]{40}$/.test(parsed.content)) return true
+ return 'invalidEthereumAddress'
+ },
+
+ bitcoinAddress: (_content: string, parsed: ParsedQRContent): true | string => {
+ if (parsed.type === 'address' && parsed.chain === 'bitcoin') return true
+ if (parsed.type === 'payment' && parsed.chain === 'bitcoin') return true
+ return 'invalidBitcoinAddress'
+ },
+
+ tronAddress: (_content: string, parsed: ParsedQRContent): true | string => {
+ if (parsed.type === 'address' && parsed.chain === 'tron') return true
+ if (parsed.type === 'payment' && parsed.chain === 'tron') return true
+ if (parsed.type === 'unknown' && /^T[a-zA-HJ-NP-Z1-9]{33}$/.test(parsed.content)) return true
+ return 'invalidTronAddress'
+ },
+
+ anyAddress: (_content: string, parsed: ParsedQRContent): true | string => {
+ if (parsed.type === 'address') return true
+ if (parsed.type === 'payment') return true
+ if (parsed.type === 'unknown' && parsed.content.length >= 26 && parsed.content.length <= 64) return true
+ return 'invalidAddress'
+ },
+
+ any: (): true => true,
+}
+
+/** 根据链类型获取验证器 */
+export function getValidatorForChain(chainType?: string): ScanValidator {
+ switch (chainType?.toLowerCase()) {
+ case 'ethereum':
+ case 'eth':
+ return scanValidators.ethereumAddress
+ case 'bitcoin':
+ case 'btc':
+ return scanValidators.bitcoinAddress
+ case 'tron':
+ case 'trx':
+ return scanValidators.tronAddress
+ default:
+ return scanValidators.anyAddress
+ }
+}
+
+/** 扫描结果事件 */
+export interface ScannerResultEvent {
+ content: string
+ parsed: ParsedQRContent
+}
+
+/** 设置扫描结果回调 */
+let scannerResultCallback: ((result: ScannerResultEvent) => void) | null = null
+
+export function setScannerResultCallback(callback: ((result: ScannerResultEvent) => void) | null) {
+ scannerResultCallback = callback
+}
+
+export function getScannerResultCallback() {
+ return scannerResultCallback
+}
diff --git a/src/stackflow/stackflow.ts b/src/stackflow/stackflow.ts
index 2c8a08e6..503ed688 100644
--- a/src/stackflow/stackflow.ts
+++ b/src/stackflow/stackflow.ts
@@ -28,7 +28,7 @@ import { NotificationsActivity } from "./activities/NotificationsActivity";
import { StakingActivity } from "./activities/StakingActivity";
import { WelcomeActivity } from "./activities/WelcomeActivity";
import { SettingsWalletChainsActivity } from "./activities/SettingsWalletChainsActivity";
-import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, WalletLockConfirmJob, TwoStepSecretConfirmJob, SetTwoStepSecretJob, MnemonicOptionsJob, ContactEditJob, ContactPickerJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob, TransferWalletLockJob, FeeEditJob } from "./activities/sheets";
+import { ChainSelectorJob, WalletRenameJob, WalletDeleteJob, WalletLockConfirmJob, TwoStepSecretConfirmJob, SetTwoStepSecretJob, MnemonicOptionsJob, ContactEditJob, ContactPickerJob, WalletAddJob, SecurityWarningJob, TransferConfirmJob, TransferWalletLockJob, FeeEditJob, ScannerJob, ContactAddConfirmJob, ContactShareJob } from "./activities/sheets";
export const { Stack, useFlow, useStepFlow, activities } = stackflow({
transitionDuration: 350,
@@ -77,6 +77,9 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
TransferConfirmJob: "/job/transfer-confirm",
TransferWalletLockJob: "/job/transfer-wallet-lock",
FeeEditJob: "/job/fee-edit",
+ ScannerJob: "/job/scanner",
+ ContactAddConfirmJob: "/job/contact-add-confirm",
+ ContactShareJob: "/job/contact-share",
},
fallbackActivity: () => "MainTabsActivity",
useHash: true,
@@ -121,6 +124,9 @@ export const { Stack, useFlow, useStepFlow, activities } = stackflow({
TransferConfirmJob,
TransferWalletLockJob,
FeeEditJob,
+ ScannerJob,
+ ContactAddConfirmJob,
+ ContactShareJob,
},
// Note: Don't set initialActivity when using historySyncPlugin
// The plugin will determine the initial activity based on the URL
diff --git a/vitest.config.ts b/vitest.config.ts
index 508ea663..6d8f8114 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -46,6 +46,13 @@ export default defineConfig({
SSR: false,
}),
},
+ server: {
+ hmr: false, // 测试环境禁用热重载
+ },
+ optimizeDeps: {
+ // 预优化这些依赖,避免运行时发现导致重载
+ include: ['react-dom/client'],
+ },
plugins: [
storybookTest({
configDir: path.join(dirname, '.storybook'),