diff --git "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/index.md" "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/index.md" index 7c8a58730..baaa623ba 100644 --- "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/index.md" +++ "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/\345\256\236\347\216\260\350\247\204\350\214\203/index.md" @@ -96,6 +96,55 @@ BioForest 链支持可选的交易密码机制: - **MUST** 处理交易密码验证 - **SHOULD** 缓存区块高度用于交易构建 +### SDK 集成 + +BioForest 链使用官方 SDK 进行交易创建和签名: + +```typescript +// SDK 位置 +src/services/bioforest-sdk/ +├── index.ts // SDK 包装器 +├── types.d.ts // 类型定义 +├── bioforest-chain-bundle.js // 预构建 SDK +├── bioforest-chain-bundle.d.ts // 类型声明 +└── genesis-block.json // 创世块配置 + +// 关键函数 +createTransferTransaction(params) // 创建转账交易 +broadcastTransaction(rpcUrl, chainId, tx) // 广播交易 +getAddressInfo(rpcUrl, chainId, address) // 获取地址信息 +verifyPayPassword(secret, payPassword, secondPublicKey) // 验证交易密码 +``` + +### 交易密码流程 + +``` +用户发起转账 + │ + ▼ +getAddressInfo() 检查 secondPublicKey + │ + ├─ 无 secondPublicKey → 直接签名 + │ + └─ 有 secondPublicKey → 需要交易密码 + │ + ▼ + 用户输入交易密码 + │ + ▼ + verifyPayPassword() 验证 + │ + ├─ 失败 → 提示错误 + │ + └─ 成功 → 使用 paySecret 签名 + │ + ▼ + createTransferTransaction() + │ + ▼ + broadcastTransaction() +``` + --- ## EVM 链 diff --git "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/09-\350\201\224\347\263\273\344\272\272\346\234\215\345\212\241/index.md" "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/09-\350\201\224\347\263\273\344\272\272\346\234\215\345\212\241/index.md" new file mode 100644 index 000000000..20919b0d1 --- /dev/null +++ "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/09-\350\201\224\347\263\273\344\272\272\346\234\215\345\212\241/index.md" @@ -0,0 +1,205 @@ +# 联系人服务 + +> 管理用户的联系人和地址簿 + +--- + +## 概述 + +联系人服务提供地址簿管理功能,支持: +- 一个联系人保存多个地址(不同链) +- 自动识别地址格式 +- 地址输入时的联系人建议 +- 联系人搜索和过滤 + +--- + +## 数据模型 + +### Contact(联系人) + +```typescript +interface Contact { + id: string // UUID + name: string // 联系人名称 + avatar?: string // 头像(可选,emoji 或图片URL) + addresses: ContactAddress[] // 多个地址 + memo?: string // 备注 + createdAt: number // 创建时间 + updatedAt: number // 更新时间 +} +``` + +### ContactAddress(联系人地址) + +```typescript +interface ContactAddress { + id: string // UUID + address: string // 地址 + chainType: ChainType // 链类型(自动检测或手动指定) + label?: string // 地址标签(如"主地址"、"交易所") + isDefault?: boolean // 是否默认地址 +} +``` + +--- + +## 地址格式识别 + +### 支持的格式 + +| 链类型 | 地址格式 | 示例 | +|-------|---------|------| +| Ethereum | 0x + 40位hex | `0x742d35Cc...` | +| BSC | 同 Ethereum | `0x742d35Cc...` | +| TRON | T + 33位base58 | `TJYs...` | +| Bitcoin Legacy | 1 + 25-34位base58 | `1A1zP1...` | +| Bitcoin P2SH | 3 + 25-34位base58 | `3J98t1...` | +| Bitcoin Bech32 | bc1 + 25-89位 | `bc1qar0...` | +| BFMeta | b + base58check | `b7ADmv...` | +| CCChain | c + base58check | `cXXXXX...` | +| PMChain | p + base58check | `pXXXXX...` | + +### 检测算法 + +```typescript +function detectAddressFormat(address: string): AddressFormatInfo { + // 1. 检查 0x 前缀 → Ethereum/BSC + // 2. 检查 T 前缀 + 长度 → TRON + // 3. 检查 1/3/bc1 前缀 → Bitcoin + // 4. 检查 BioForest 前缀 (b/c/p/B/g/w/e/m) → 对应链 + // 5. 返回 unknown +} +``` + +--- + +## 服务接口 + +### IContactService + +```typescript +interface IContactService { + // 联系人 CRUD + getContacts(): Contact[] + getContactById(id: string): Contact | undefined + addContact(contact: Omit): Contact + updateContact(id: string, updates: Partial): void + deleteContact(id: string): void + + // 地址操作 + addAddressToContact(contactId: string, address: ContactAddress): void + removeAddressFromContact(contactId: string, addressId: string): void + + // 搜索和建议 + searchContacts(query: string): Contact[] + suggestContacts(partialAddress: string): ContactSuggestion[] + getContactByAddress(address: string): Contact | undefined + + // 导入导出 + importContacts(contacts: Contact[]): number + exportContacts(): Contact[] +} +``` + +### ContactSuggestion(联系人建议) + +```typescript +interface ContactSuggestion { + contact: Contact + matchedAddress: ContactAddress + matchType: 'exact' | 'prefix' | 'name' + score: number // 匹配分数,用于排序 +} +``` + +--- + +## 地址输入增强 + +### AddressInput 组件增强 + +``` +┌─────────────────────────────────────────┐ +│ [图标] 输入地址或选择联系人 [扫描] │ +├─────────────────────────────────────────┤ +│ 建议联系人: │ +│ ┌─────────────────────────────────────┐ │ +│ │ 👤 Alice │ │ +│ │ b7ADmv... (BFMeta) │ │ +│ └─────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 👤 Bob │ │ +│ │ 0x742d... (Ethereum) │ │ +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +### 交互流程 + +1. 用户开始输入地址 +2. 实时搜索匹配的联系人(按名称或地址前缀) +3. 显示建议列表(最多5个) +4. 用户选择联系人后,自动填入对应地址 +5. 根据当前选择的链,优先显示该链的地址 + +--- + +## 存储 + +使用 localStorage 持久化,key 为 `bfm_contacts`。 + +```typescript +// 存储格式 +{ + version: 2, // 数据版本,用于迁移 + contacts: Contact[] +} +``` + +### 数据迁移 + +从 v1(单地址)迁移到 v2(多地址): + +```typescript +function migrateV1ToV2(v1Contact: V1Contact): Contact { + return { + ...v1Contact, + addresses: [{ + id: crypto.randomUUID(), + address: v1Contact.address, + chainType: v1Contact.chain ?? detectAddressFormat(v1Contact.address).chainType, + }] + } +} +``` + +--- + +## 与转账流程集成 + +### 发送页面 + +1. 地址输入框显示联系人建议 +2. 选择联系人时,如果有多个地址,弹出地址选择器 +3. 如果联系人只有一个地址,直接填入 + +### 交易历史 + +1. 显示交易对方时,优先显示联系人名称 +2. 提供"添加到联系人"操作 + +--- + +## 安全考虑 + +- 联系人数据不加密(非敏感数据) +- 删除联系人需要密码确认(防止误操作) +- 导出功能可选加密 + +--- + +## 相关链接 + +- [08-钱包数据存储](../08-钱包数据存储/) - 数据存储规范 +- [ITransactionService](../02-链服务/ITransactionService/) - 交易服务 diff --git "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/index.md" "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/index.md" index 80f930fb0..c53dd1c03 100644 --- "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/index.md" +++ "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/index.md" @@ -82,6 +82,16 @@ Schema-first 的服务定义系统,提供类型安全和 Mock 控制。 - 数据迁移策略 - DWEB 生物识别增强 +### [09-联系人服务](./09-联系人服务/) + +定义联系人和地址簿的管理规范。 + +**要点**: +- 一个联系人支持多个地址 +- 自动识别地址格式(检测链类型) +- 地址输入时的联系人建议 +- 与转账流程的集成 + --- ## 设计原则 diff --git a/e2e/__screenshots__/Desktop-Chrome/address-book-with-contacts.png b/e2e/__screenshots__/Desktop-Chrome/address-book-with-contacts.png new file mode 100644 index 000000000..957447cd5 Binary files /dev/null and b/e2e/__screenshots__/Desktop-Chrome/address-book-with-contacts.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/home-chain-selector.png b/e2e/__screenshots__/Desktop-Chrome/home-chain-selector.png index c0c9ee44f..f4721ee3e 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/home-chain-selector.png and b/e2e/__screenshots__/Desktop-Chrome/home-chain-selector.png differ diff --git a/e2e/__screenshots__/Desktop-Chrome/wallet-detail.png b/e2e/__screenshots__/Desktop-Chrome/wallet-detail.png index 227bb5671..7195161a0 100644 Binary files a/e2e/__screenshots__/Desktop-Chrome/wallet-detail.png and b/e2e/__screenshots__/Desktop-Chrome/wallet-detail.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/address-book-with-contacts.png b/e2e/__screenshots__/Mobile-Chrome/address-book-with-contacts.png new file mode 100644 index 000000000..c2768545e Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/address-book-with-contacts.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/history-empty.png b/e2e/__screenshots__/Mobile-Chrome/history-empty.png index 33466b06d..aa0ef0bb2 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/history-empty.png and b/e2e/__screenshots__/Mobile-Chrome/history-empty.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/home-chain-selector.png b/e2e/__screenshots__/Mobile-Chrome/home-chain-selector.png index 1bb2917e9..057904718 100644 Binary files a/e2e/__screenshots__/Mobile-Chrome/home-chain-selector.png and b/e2e/__screenshots__/Mobile-Chrome/home-chain-selector.png differ diff --git a/e2e/pages.spec.ts b/e2e/pages.spec.ts index 389d0a5ca..33d082958 100644 --- a/e2e/pages.spec.ts +++ b/e2e/pages.spec.ts @@ -247,6 +247,58 @@ test.describe('通知页面', () => { }) }) +// 测试地址簿数据(多地址联系人) +const TEST_CONTACTS_DATA = { + contacts: [ + { + id: 'contact-1', + name: 'Alice', + addresses: [ + { id: 'addr-1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum', isDefault: true }, + ], + memo: '同事', + createdAt: Date.now() - 86400000, + updatedAt: Date.now() - 86400000, + }, + { + id: 'contact-2', + name: 'Bob', + addresses: [ + { id: 'addr-2', address: '0xabcdef1234567890abcdef1234567890abcdef12', chainType: 'ethereum', isDefault: true }, + { id: 'addr-3', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', chainType: 'bfmeta' }, + ], + createdAt: Date.now() - 172800000, + updatedAt: Date.now() - 172800000, + }, + { + 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' }, + ], + memo: '支持多链转账', + createdAt: Date.now() - 259200000, + updatedAt: Date.now() - 259200000, + }, + ], + isInitialized: true, + version: 2, +} + +// 辅助函数:设置测试联系人 +async function setupTestContacts(page: Page, targetUrl: string = '/address-book') { + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data.wallet)) + localStorage.setItem('bfm_address_book', JSON.stringify(data.contacts)) + }, { wallet: TEST_WALLET_DATA, contacts: TEST_CONTACTS_DATA }) + + const hashUrl = `/#${targetUrl}` + await page.goto(hashUrl) + await page.waitForLoadState('networkidle') +} + test.describe('地址簿页面', () => { test('地址簿截图 - 空状态', async ({ page }) => { await setupTestWallet(page, '/address-book') @@ -254,6 +306,29 @@ test.describe('地址簿页面', () => { await expect(page).toHaveScreenshot('address-book-empty.png') }) + + test('地址簿截图 - 有联系人', async ({ page }) => { + await setupTestContacts(page, '/address-book') + await waitForAppReady(page) + // Wait for contacts to load and render + await expect(page.locator('text=Alice')).toBeVisible({ timeout: 10000 }) + + await expect(page).toHaveScreenshot('address-book-with-contacts.png') + }) + + test('地址簿 - 多地址联系人显示', async ({ page }) => { + await setupTestContacts(page, '/address-book') + await waitForAppReady(page) + + // 验证多地址联系人显示 (+N) 后缀 + const multiAddressContact = page.locator('text=Bob') + await expect(multiAddressContact).toBeVisible() + // Bob has 2 addresses, should show (+1) + await expect(page.locator('text=+1')).toBeVisible() + + // 多链用户 has 3 addresses, should show (+2) + await expect(page.locator('text=+2')).toBeVisible() + }) }) test.describe('钱包列表页面', () => { diff --git a/src/components/transfer/address-input.stories.tsx b/src/components/transfer/address-input.stories.tsx index bc277eaa9..d2b1ca045 100644 --- a/src/components/transfer/address-input.stories.tsx +++ b/src/components/transfer/address-input.stories.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { AddressInput } from './address-input'; +import { addressBookActions } from '@/stores'; const meta: Meta = { title: 'Transfer/AddressInput', @@ -79,3 +80,54 @@ export const TransferForm: Story = { ); }, }; + +/** + * 带联系人建议 - 输入联系人名称或地址前缀会显示建议 + */ +export const WithContactSuggestions: Story = { + decorators: [ + (Story) => { + // Setup contacts for suggestions + addressBookActions.clearAll(); + addressBookActions.addContact({ + name: 'Alice', + addresses: [ + { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum', isDefault: true }, + ], + memo: '同事', + }); + addressBookActions.addContact({ + name: 'Bob', + addresses: [ + { id: '2', address: '0xabcdef1234567890abcdef1234567890abcdef12', chainType: 'ethereum', isDefault: true }, + { id: '3', address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', chainType: 'bfmeta' }, + ], + }); + addressBookActions.addContact({ + name: 'Charlie', + addresses: [ + { id: '4', address: '0x9876543210fedcba9876543210fedcba98765432', chainType: 'ethereum', isDefault: true }, + ], + memo: '朋友', + }); + return ; + }, + ], + render: () => { + const [address, setAddress] = useState(''); + + return ( +
+

试着输入 "Al"、"Bob" 或 "0x12" 来查看建议

+ alert('Open QR scanner')} + showSuggestions + /> +

当前值: {address || '(空)'}

+
+ ); + }, +}; diff --git a/src/components/transfer/address-input.tsx b/src/components/transfer/address-input.tsx index 3a59a8cbe..d7b74e8fc 100644 --- a/src/components/transfer/address-input.tsx +++ b/src/components/transfer/address-input.tsx @@ -1,8 +1,10 @@ -import { useState, forwardRef, useId } from 'react'; +import { useState, forwardRef, useId, useMemo, useCallback, useRef, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; -import { IconLineScan as ScanLine, IconClipboardCopy as ClipboardPaste } from '@tabler/icons-react'; +import { useStore } from '@tanstack/react-store'; +import { IconLineScan as ScanLine, IconClipboardCopy as ClipboardPaste, IconUser } from '@tabler/icons-react'; import { clipboardService } from '@/services/clipboard'; +import { addressBookStore, addressBookSelectors, type ChainType, type ContactSuggestion } from '@/stores'; interface AddressInputProps extends Omit, 'onChange'> { value?: string | undefined; @@ -11,6 +13,10 @@ interface AddressInputProps extends Omit void) | undefined; error?: string | undefined; label?: string | undefined; + /** Chain type to filter contact addresses */ + chainType?: ChainType | undefined; + /** Enable contact suggestions dropdown */ + showSuggestions?: boolean | undefined; } function isValidAddress(address: string): boolean { @@ -25,16 +31,50 @@ function isValidAddress(address: string): boolean { } const AddressInput = forwardRef( - ({ value = '', onChange, onScan, onPaste, error, label, className, ...props }, ref) => { + ({ value = '', onChange, onScan, onPaste, error, label, className, chainType, showSuggestions = true, ...props }, ref) => { const [focused, setFocused] = useState(false); const [internalValue, setInternalValue] = useState(value); + const [showDropdown, setShowDropdown] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); const { t } = useTranslation(); const errorId = useId(); + const listboxId = useId(); + + // Get contacts from store + const addressBookState = useStore(addressBookStore); const currentValue = value || internalValue; const isValid = isValidAddress(currentValue); const hasError = !!(error || (!isValid && currentValue)); + // Get contact suggestions based on input + const suggestions = useMemo((): ContactSuggestion[] => { + if (!showSuggestions || !currentValue || currentValue.length < 1) return []; + return addressBookSelectors.suggestContacts(addressBookState, currentValue, chainType).slice(0, 5); + }, [addressBookState, currentValue, chainType, showSuggestions]); + + // Show dropdown when there are suggestions and input is focused + useEffect(() => { + if (focused && suggestions.length > 0 && currentValue.length > 0) { + setShowDropdown(true); + setSelectedIndex(-1); + } else { + setShowDropdown(false); + } + }, [focused, suggestions.length, currentValue]); + + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value.trim(); setInternalValue(newValue); @@ -53,56 +93,125 @@ const AddressInput = forwardRef( } }; + const handleSelectSuggestion = useCallback((suggestion: ContactSuggestion) => { + setInternalValue(suggestion.matchedAddress.address); + onChange?.(suggestion.matchedAddress.address); + setShowDropdown(false); + }, [onChange]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!showDropdown || suggestions.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + case 'Enter': + if (selectedIndex >= 0 && suggestions[selectedIndex]) { + e.preventDefault(); + handleSelectSuggestion(suggestions[selectedIndex]); + } + break; + case 'Escape': + setShowDropdown(false); + break; + } + }; + return ( -
+
{label && } -
- setFocused(true)} - onBlur={() => setFocused(false)} - className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent font-mono text-sm outline-none" - placeholder={t('common:addressPlaceholder')} - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - aria-invalid={hasError} - aria-describedby={hasError ? errorId : undefined} - {...props} - /> - -
- {onScan && ( +
+
+ setFocused(true)} + onBlur={() => setTimeout(() => setFocused(false), 150)} + onKeyDown={handleKeyDown} + className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent font-mono text-sm outline-none" + placeholder={t('common:addressPlaceholder')} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + aria-invalid={hasError} + aria-describedby={hasError ? errorId : undefined} + aria-expanded={showDropdown} + aria-controls={showDropdown ? listboxId : undefined} + aria-activedescendant={selectedIndex >= 0 ? `suggestion-${selectedIndex}` : undefined} + role="combobox" + {...props} + /> + +
+ {onScan && ( + + )} - )} - +
+ + {/* Contact suggestions dropdown */} + {showDropdown && suggestions.length > 0 && ( +
    + {suggestions.map((suggestion, index) => ( +
  • handleSelectSuggestion(suggestion)} + > +
    + +
    +
    +

    {suggestion.contact.name}

    +

    {suggestion.matchedAddress.address}

    +
    + {suggestion.matchedAddress.chainType} +
  • + ))} +
+ )}
{/* Error message with aria-live for screen readers */} diff --git a/src/lib/address-format.ts b/src/lib/address-format.ts new file mode 100644 index 000000000..695a27e94 --- /dev/null +++ b/src/lib/address-format.ts @@ -0,0 +1,126 @@ +/** + * Address Format Detection + * + * Detects chain type from address format patterns. + */ + +import type { ChainType } from '@/stores' +import { isValidBioforestAddress, isBioforestChain, type BioforestChainType } from './crypto' + +/** Address format detection result */ +export interface AddressFormatInfo { + /** Detected chain type, or null if unknown */ + chainType: ChainType | null + /** Whether the address format is valid */ + isValid: boolean + /** Human-readable format description */ + formatDescription: string +} + +/** BioForest chain prefixes (values are ChainType that are also BioforestChainType) */ +const BIOFOREST_PREFIXES: Record = { + b: 'bfmeta', + c: 'ccchain', + p: 'pmchain', + B: 'bfchainv2', + g: 'btgmeta', + w: 'biwmeta', + e: 'ethmeta', + m: 'malibu', +} + +/** Type guard to check if chainType is a BioforestChainType */ +function asBioforestChain(chain: ChainType): BioforestChainType | undefined { + return isBioforestChain(chain) ? chain : undefined +} + +/** + * Detect chain type from address format + */ +export function detectAddressFormat(address: string): AddressFormatInfo { + if (!address || typeof address !== 'string') { + return { chainType: null, isValid: false, formatDescription: 'Empty' } + } + + const trimmed = address.trim() + + // Ethereum-like address (0x prefix, 40 hex chars) + if (/^0x[a-fA-F0-9]{40}$/.test(trimmed)) { + return { chainType: 'ethereum', isValid: true, formatDescription: 'Ethereum (0x...)' } + } + + // TRON address (T prefix, 33 base58 chars) + if (/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(trimmed)) { + return { chainType: 'tron', isValid: true, formatDescription: 'TRON (T...)' } + } + + // Bitcoin Legacy (1 prefix) + if (/^1[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(trimmed)) { + return { chainType: 'bitcoin', isValid: true, formatDescription: 'Bitcoin Legacy (1...)' } + } + + // Bitcoin P2SH (3 prefix) + if (/^3[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(trimmed)) { + return { chainType: 'bitcoin', isValid: true, formatDescription: 'Bitcoin P2SH (3...)' } + } + + // Bitcoin Bech32 (bc1 prefix) + if (/^bc1[a-zA-HJ-NP-Z0-9]{25,89}$/.test(trimmed)) { + return { chainType: 'bitcoin', isValid: true, formatDescription: 'Bitcoin Bech32 (bc1...)' } + } + + // BioForest chain addresses + const firstChar = trimmed[0] + if (firstChar && firstChar in BIOFOREST_PREFIXES) { + const chainType = BIOFOREST_PREFIXES[firstChar] + if (chainType) { + const bioforestChain = asBioforestChain(chainType) + const isValid = isValidBioforestAddress(trimmed, bioforestChain) + if (isValid) { + return { + chainType, + isValid: true, + formatDescription: `BioForest (${firstChar}...)`, + } + } + } + } + + // Generic BioForest address check (any valid prefix) + if (isValidBioforestAddress(trimmed)) { + const detectedChain = firstChar ? BIOFOREST_PREFIXES[firstChar] : null + return { + chainType: detectedChain ?? null, + isValid: true, + formatDescription: 'BioForest', + } + } + + // Unknown format + return { chainType: null, isValid: false, formatDescription: 'Unknown' } +} + +/** + * Check if address is valid for a specific chain + */ +export function isValidAddressForChain(address: string, chainType: ChainType): boolean { + const info = detectAddressFormat(address) + if (!info.isValid) return false + if (info.chainType === null) return false + return info.chainType === chainType +} + +/** + * Get all compatible chain types for an address + */ +export function getCompatibleChains(address: string): ChainType[] { + const info = detectAddressFormat(address) + if (!info.isValid || info.chainType === null) return [] + + // Ethereum addresses are compatible with Ethereum and BSC + if (info.chainType === 'ethereum') { + return ['ethereum', 'binance'] + } + + return [info.chainType] +} diff --git a/src/pages/address-book/index.stories.tsx b/src/pages/address-book/index.stories.tsx index 896b6ced9..525646962 100644 --- a/src/pages/address-book/index.stories.tsx +++ b/src/pages/address-book/index.stories.tsx @@ -15,6 +15,11 @@ const meta: Meta = { export default meta type Story = StoryObj +// Helper to create addresses +function createAddresses(address: string, chainType = 'ethereum') { + return [{ id: crypto.randomUUID(), address, chainType: chainType as 'ethereum' }] +} + const resetStores = () => { addressBookActions.clearAll() walletStore.setState({ @@ -23,7 +28,7 @@ const resetStores = () => { isInitialized: true, isLoading: false, chainPreferences: {}, - selectedChain: 'ethereum', + selectedChain: 'ethereum', }) } @@ -48,18 +53,17 @@ export const WithContacts: Story = { resetStores() addressBookActions.addContact({ name: 'Alice', - address: '0x1234567890abcdef1234567890abcdef12345678', + addresses: createAddresses('0x1234567890abcdef1234567890abcdef12345678'), memo: '同事', }) addressBookActions.addContact({ name: 'Bob', - address: '0xabcdef1234567890abcdef1234567890abcdef12', + addresses: createAddresses('0xabcdef1234567890abcdef1234567890abcdef12'), }) addressBookActions.addContact({ name: 'Charlie', - address: '0x9876543210fedcba9876543210fedcba98765432', + addresses: createAddresses('0x9876543210fedcba9876543210fedcba98765432'), memo: '朋友', - chain: 'ethereum', }) return }, @@ -74,18 +78,42 @@ export const ManyContacts: Story = { (Story) => { resetStores() for (let i = 1; i <= 10; i++) { - const contact = { + addressBookActions.addContact({ name: `联系人 ${i}`, - address: `0x${i.toString().padStart(40, '0')}`, + addresses: createAddresses(`0x${i.toString().padStart(40, '0')}`), ...(i % 3 === 0 ? { memo: '有备注' } : {}), - } - addressBookActions.addContact(contact) + }) } return }, ], } +/** + * 多地址联系人 + */ +export const MultipleAddresses: Story = { + decorators: [ + (Story) => { + resetStores() + addressBookActions.addContact({ + name: 'Multi-Chain User', + addresses: [ + { id: '1', address: '0x1234567890abcdef1234567890abcdef12345678', chainType: 'ethereum' }, + { id: '2', address: 'b7ADmvZJJ3n3aDxkvwbXxJX1oGgeiCzL11', chainType: 'bfmeta' }, + { id: '3', address: 'TJYs1234567890abcdef1234567890abc', chainType: 'tron' }, + ], + memo: '多链用户', + }) + addressBookActions.addContact({ + name: 'Single Address', + addresses: createAddresses('0xabcdef1234567890abcdef1234567890abcdef12'), + }) + return + }, + ], +} + /** * 长名称和地址 */ @@ -95,7 +123,7 @@ export const LongContent: Story = { resetStores() addressBookActions.addContact({ name: '这是一个很长的联系人名称', - address: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + addresses: createAddresses('0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'), memo: '这是一个很长的备注信息,用于测试文本截断效果', }) return diff --git a/src/pages/address-book/index.test.tsx b/src/pages/address-book/index.test.tsx index db85d2603..7ecd5400a 100644 --- a/src/pages/address-book/index.test.tsx +++ b/src/pages/address-book/index.test.tsx @@ -5,6 +5,11 @@ import { addressBookActions, walletStore } from '@/stores' const mockPush = vi.fn() +// Helper to create addresses +function createAddresses(address: string, chainType = 'ethereum') { + return [{ id: crypto.randomUUID(), address, chainType: chainType as 'ethereum' }] +} + // Mock dependencies vi.mock('@/stackflow', () => ({ useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }), @@ -53,8 +58,8 @@ describe('AddressBookPage', () => { }) it('renders contact list when contacts exist', () => { - addressBookActions.addContact({ name: 'Alice', address: '0x1111' }) - addressBookActions.addContact({ name: 'Bob', address: '0x2222' }) + addressBookActions.addContact({ name: 'Alice', addresses: createAddresses('0x1111') }) + addressBookActions.addContact({ name: 'Bob', addresses: createAddresses('0x2222') }) render() @@ -64,8 +69,8 @@ describe('AddressBookPage', () => { }) it('filters contacts by search query', () => { - addressBookActions.addContact({ name: 'Alice', address: '0x1111' }) - addressBookActions.addContact({ name: 'Bob', address: '0x2222' }) + addressBookActions.addContact({ name: 'Alice', addresses: createAddresses('0x1111') }) + addressBookActions.addContact({ name: 'Bob', addresses: createAddresses('0x2222') }) render() @@ -77,7 +82,7 @@ describe('AddressBookPage', () => { }) it('shows empty search result message', () => { - addressBookActions.addContact({ name: 'Alice', address: '0x1111' }) + addressBookActions.addContact({ name: 'Alice', addresses: createAddresses('0x1111') }) render() @@ -98,7 +103,7 @@ describe('AddressBookPage', () => { }) it('opens edit contact sheet when edit is clicked', () => { - const contact = addressBookActions.addContact({ name: 'Alice', address: '0x1111' }) + const contact = addressBookActions.addContact({ name: 'Alice', addresses: createAddresses('0x1111') }) render() @@ -114,7 +119,7 @@ describe('AddressBookPage', () => { }) it('deletes contact directly when no wallet exists', () => { - addressBookActions.addContact({ name: 'Alice', address: '0x1111' }) + addressBookActions.addContact({ name: 'Alice', addresses: createAddresses('0x1111') }) render() @@ -138,4 +143,19 @@ describe('AddressBookPage', () => { expect(mockGoBack).toHaveBeenCalled() }) + + it('displays multiple addresses count', () => { + addressBookActions.addContact({ + name: 'Multi', + addresses: [ + { id: '1', address: '0x1111', chainType: 'ethereum' }, + { id: '2', address: 'b7ADmv...', chainType: 'bfmeta' }, + ], + }) + + render() + + expect(screen.getByText('Multi')).toBeInTheDocument() + expect(screen.getByText(/\+1/)).toBeInTheDocument() // Shows (+1) for additional addresses + }) }) diff --git a/src/pages/address-book/index.tsx b/src/pages/address-book/index.tsx index ab43f51f7..ee9b4d071 100644 --- a/src/pages/address-book/index.tsx +++ b/src/pages/address-book/index.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useRef } from 'react'; +import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation, useFlow } from '@/stackflow'; import { setPasswordConfirmCallback } from '@/stackflow/activities/sheets'; @@ -25,9 +25,17 @@ export function AddressBookPage() { const { t } = useTranslation('common'); const { goBack } = useNavigation(); const { push } = useFlow(); - const contacts = useStore(addressBookStore, (s) => s.contacts); + const addressBookState = useStore(addressBookStore); + const contacts = addressBookState.contacts; const currentWallet = useStore(walletStore, walletSelectors.getCurrentWallet); + // Initialize address book from storage + useEffect(() => { + if (!addressBookState.isInitialized) { + addressBookActions.initialize(); + } + }, [addressBookState.isInitialized]); + const [searchQuery, setSearchQuery] = useState(''); const deletingContactRef = useRef(null); @@ -173,7 +181,12 @@ function ContactListItem({ contact, onEdit, onDelete }: ContactListItemProps) { {/* 信息 */}

{contact.name}

-

{contact.address}

+ {contact.addresses.length > 0 && ( +

+ {contact.addresses[0]?.address} + {contact.addresses.length > 1 && ` (+${contact.addresses.length - 1})`} +

+ )} {contact.memo &&

{contact.memo}

}
diff --git a/src/services/migration/migration-service.test.ts b/src/services/migration/migration-service.test.ts index 76ff8b7f9..86f21600f 100644 --- a/src/services/migration/migration-service.test.ts +++ b/src/services/migration/migration-service.test.ts @@ -287,7 +287,7 @@ describe('migration-service', () => { vi.mocked(transformAddressBookEntry).mockImplementation((entry) => ({ id: entry.addressBookId, name: entry.name, - address: entry.address, + addresses: [{ id: 'addr-1', address: entry.address, chainType: 'ethereum' as const }], createdAt: 1, updatedAt: 1, })) diff --git a/src/services/migration/mpay-transformer.test.ts b/src/services/migration/mpay-transformer.test.ts index 2800c25f5..c39c1456f 100644 --- a/src/services/migration/mpay-transformer.test.ts +++ b/src/services/migration/mpay-transformer.test.ts @@ -250,8 +250,9 @@ describe('mpay-transformer', () => { expect(contact.id).toBe('addr-book-1') expect(contact.name).toBe('Alice') - expect(contact.address).toBe('bfm123456') - expect(contact.chain).toBe('bfmeta') + expect(contact.addresses).toHaveLength(1) + expect(contact.addresses[0]?.address).toBe('bfm123456') + expect(contact.addresses[0]?.chainType).toBe('bfmeta') expect(contact.memo).toBe('My friend') expect(contact.createdAt).toBeTypeOf('number') expect(contact.updatedAt).toBeTypeOf('number') @@ -268,8 +269,8 @@ describe('mpay-transformer', () => { expect(contact.id).toBe('addr-book-2') expect(contact.name).toBe('Bob') - expect(contact.address).toBe('0xabc123') - expect(contact.chain).toBeUndefined() + expect(contact.addresses[0]?.address).toBe('0xabc123') + expect(contact.addresses[0]?.chainType).toBe('ethereum') // Default expect(contact.memo).toBeUndefined() }) @@ -283,7 +284,7 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) - expect(contact.chain).toBeUndefined() + expect(contact.addresses[0]?.chainType).toBe('ethereum') // Default when no chain detected }) it('should select first mappable chain from multi-chain entry', () => { @@ -297,7 +298,7 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) // First mappable chain should be selected - expect(contact.chain).toBe('ethereum') + expect(contact.addresses[0]?.chainType).toBe('ethereum') }) it('should skip unmappable chains and use first mappable', () => { @@ -310,10 +311,10 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) - expect(contact.chain).toBe('bfmeta') + expect(contact.addresses[0]?.chainType).toBe('bfmeta') }) - it('should return undefined chain when all chains are unmappable', () => { + it('should use ethereum as default when all chains are unmappable', () => { const entry: MpayAddressBookEntry = { addressBookId: 'addr-book-6', name: 'All Unknown', @@ -323,7 +324,7 @@ describe('mpay-transformer', () => { const contact = transformAddressBookEntry(entry) - expect(contact.chain).toBeUndefined() + expect(contact.addresses[0]?.chainType).toBe('ethereum') // Default }) it('should handle entry without remarks', () => { diff --git a/src/services/migration/mpay-transformer.ts b/src/services/migration/mpay-transformer.ts index 4ce032d85..1120e72e5 100644 --- a/src/services/migration/mpay-transformer.ts +++ b/src/services/migration/mpay-transformer.ts @@ -110,19 +110,23 @@ function determineChainFromList(chainList?: string[]): ChainType | undefined { */ export function transformAddressBookEntry(entry: MpayAddressBookEntry): Contact { const now = Date.now() + const chain = determineChainFromList(entry.chainList) ?? 'ethereum' const contact: Contact = { id: entry.addressBookId, name: entry.name, - address: entry.address, + addresses: [ + { + id: crypto.randomUUID(), + address: entry.address, + chainType: chain, + isDefault: true, + }, + ], createdAt: now, updatedAt: now, } - const chain = determineChainFromList(entry.chainList) - if (chain) { - contact.chain = chain - } if (entry.remarks) { contact.memo = entry.remarks } diff --git a/src/stackflow/activities/sheets/ContactEditJob.tsx b/src/stackflow/activities/sheets/ContactEditJob.tsx index 1de54eab1..702102e27 100644 --- a/src/stackflow/activities/sheets/ContactEditJob.tsx +++ b/src/stackflow/activities/sheets/ContactEditJob.tsx @@ -4,7 +4,8 @@ import { BottomSheet } from "@/components/layout/bottom-sheet"; import { useTranslation } from "react-i18next"; import { useStore } from "@tanstack/react-store"; import { cn } from "@/lib/utils"; -import { addressBookStore, addressBookActions, type ChainType } from "@/stores"; +import { addressBookStore, addressBookActions, addressBookSelectors, type ChainType } from "@/stores"; +import { detectAddressFormat } from "@/lib/address-format"; import { IconUser as User, IconWallet as Wallet, IconFileText as FileText } from "@tabler/icons-react"; import { useFlow } from "../../stackflow"; import { ActivityParamsProvider, useActivityParams } from "../../hooks"; @@ -28,13 +29,18 @@ function ContactEditJobContent() { const isEditing = !!contact; + // Get default address for editing + const defaultAddress = contact + ? addressBookSelectors.getDefaultAddress(contact, defaultChain as ChainType | undefined) + : undefined; + useEffect(() => { if (contact) { setName(contact.name); - setAddress(contact.address); - setMemo(contact.memo || ""); + setAddress(defaultAddress?.address ?? ""); + setMemo(contact.memo ?? ""); } - }, [contact]); + }, [contact, defaultAddress]); const handleSave = useCallback(() => { const trimmedName = name.trim(); @@ -43,23 +49,45 @@ function ContactEditJobContent() { if (!trimmedName || !trimmedAddress) return; + // Detect chain type from address format + const detectedFormat = detectAddressFormat(trimmedAddress); + const chainType = (defaultChain as ChainType) ?? detectedFormat.chainType ?? "ethereum"; + if (isEditing && contact) { + // Update contact name and memo addressBookActions.updateContact(contact.id, { name: trimmedName, - address: trimmedAddress, - ...(trimmedMemo && { memo: trimmedMemo }), + ...(trimmedMemo ? { memo: trimmedMemo } : {}), }); + + // Update address if changed + if (defaultAddress && defaultAddress.address !== trimmedAddress) { + // Remove old address and add new one + addressBookActions.removeAddressFromContact(contact.id, defaultAddress.id); + addressBookActions.addAddressToContact(contact.id, { + address: trimmedAddress, + chainType, + isDefault: true, + }); + } } else { + // Add new contact with single address addressBookActions.addContact({ name: trimmedName, - address: trimmedAddress, - chain: (defaultChain as ChainType) || "ethereum", - ...(trimmedMemo && { memo: trimmedMemo }), + addresses: [ + { + id: crypto.randomUUID(), + address: trimmedAddress, + chainType, + isDefault: true, + }, + ], + ...(trimmedMemo ? { memo: trimmedMemo } : {}), }); } pop(); - }, [name, address, memo, isEditing, contact, defaultChain, pop]); + }, [name, address, memo, isEditing, contact, defaultAddress, defaultChain, pop]); const canSave = name.trim().length > 0 && address.trim().length > 0; diff --git a/src/stackflow/activities/tabs/TransferTab.tsx b/src/stackflow/activities/tabs/TransferTab.tsx index 23dd2cc22..402b4040d 100644 --- a/src/stackflow/activities/tabs/TransferTab.tsx +++ b/src/stackflow/activities/tabs/TransferTab.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { PageHeader } from "@/components/layout/page-header"; import { TransactionItem } from "@/components/transaction/transaction-item"; import { useTransactionHistoryQuery } from "@/queries"; -import { addressBookActions, addressBookStore, useCurrentWallet, useSelectedChain } from "@/stores"; +import { addressBookActions, addressBookStore, addressBookSelectors, useCurrentWallet, useSelectedChain, type Contact } from "@/stores"; import { IconSend } from "@tabler/icons-react"; export function TransferTab() { @@ -30,11 +30,19 @@ export function TransferTab() { const recentContacts = useMemo(() => { const filtered = selectedChain - ? contacts.filter((contact) => !contact.chain || contact.chain === selectedChain) + ? contacts.filter((contact) => + contact.addresses.some((addr) => addr.chainType === selectedChain) + ) : contacts; return filtered.slice(0, 4); }, [contacts, selectedChain]); + // Helper to get primary address for a contact + const getPrimaryAddress = (contact: Contact): string => { + const addr = addressBookSelectors.getDefaultAddress(contact, selectedChain ?? undefined); + return addr?.address ?? contact.addresses[0]?.address ?? ''; + }; + const recentTransactions = useMemo(() => { const filtered = selectedChain ? transactions.filter((tx) => tx.chain === selectedChain) @@ -62,7 +70,7 @@ export function TransferTab() {