From 4d97b65c38f4e8d15e3418fdb6af1fabfd029c7d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 09:40:45 +0800 Subject: [PATCH 1/5] docs: add contact service documentation and SDK integration notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 09-联系人服务 section to white-book - Multi-address contact model - Address format detection - Contact suggestions in address input - Integration with transfer flow - Update 实现规范 with SDK integration details - BioForest SDK structure - Pay password flow diagram - Add address-format.ts utility (WIP) --- .../index.md" | 49 +++++ .../index.md" | 205 ++++++++++++++++++ .../index.md" | 10 + src/lib/address-format.ts | 118 ++++++++++ 4 files changed, 382 insertions(+) create mode 100644 "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" create mode 100644 src/lib/address-format.ts 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/src/lib/address-format.ts b/src/lib/address-format.ts new file mode 100644 index 000000000..d3735248d --- /dev/null +++ b/src/lib/address-format.ts @@ -0,0 +1,118 @@ +/** + * Address Format Detection + * + * Detects chain type from address format patterns. + */ + +import type { ChainType } from '@/stores' +import { isValidBioforestAddress } 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 */ +const BIOFOREST_PREFIXES: Record = { + b: 'bfmeta', + c: 'ccchain', + p: 'pmchain', + B: 'bfchainv2', + g: 'btgmeta', + w: 'biwmeta', + e: 'ethmeta', + m: 'malibu', +} + +/** + * 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] + const isValid = isValidBioforestAddress(trimmed, chainType) + if (isValid) { + return { + chainType, + isValid: true, + formatDescription: `BioForest (${firstChar}...)`, + } + } + } + + // Generic BioForest address check (any valid prefix) + if (isValidBioforestAddress(trimmed)) { + const chainType = BIOFOREST_PREFIXES[firstChar] ?? null + return { + chainType, + 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] +} From 63c3c110b0662e34ff29a317c5d769fbd1a86f67 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 23 Dec 2025 09:48:02 +0800 Subject: [PATCH 2/5] WIP: update Contact type to support multiple addresses Core changes: - Contact now has 'addresses: ContactAddress[]' instead of 'address: string' - Each ContactAddress has chainType, label, isDefault - Added data migration from v1 (single address) to v2 (multi address) - Updated addressBookSelectors with suggestContacts() - Updated ContactEditJob, TransferTab, AddressBookPage - Added address-format detection utility Still TODO: - Update test files (address-book, migration, stories) - Add AddressInput contact suggestions component - Create PR --- src/pages/address-book/index.tsx | 7 +- src/services/migration/mpay-transformer.ts | 14 +- .../activities/sheets/ContactEditJob.tsx | 48 +- src/stackflow/activities/tabs/TransferTab.tsx | 14 +- src/stores/address-book.test.ts | 219 ++++++--- src/stores/address-book.ts | 416 ++++++++++++++---- src/stores/index.ts | 2 +- 7 files changed, 570 insertions(+), 150 deletions(-) diff --git a/src/pages/address-book/index.tsx b/src/pages/address-book/index.tsx index ab43f51f7..5d7ec82c0 100644 --- a/src/pages/address-book/index.tsx +++ b/src/pages/address-book/index.tsx @@ -173,7 +173,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/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() { + )} - )} - + + + {/* 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 */}