From 8bb983eb03f11e414462da800625ee8bb8df4c63 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 14:17:23 +0800 Subject: [PATCH 01/11] fix(bioforest): add beginEpochTime to transaction timestamps BioForest chain timestamps are relative to the genesis block's beginEpochTime, not absolute Unix timestamps. This fix adds the epoch time offset to correctly display transaction times. Before: timestamps showed 1970 (only relative seconds * 1000) After: timestamps show correct dates (relative seconds * 1000 + epochTime) Changes: - Add getBeginEpochTime() to fetch and cache epoch time from SDK - Add toAbsoluteTimestamp() helper for timestamp conversion - Fix getTransaction() to use absolute timestamps - Fix getTransactionHistory() to use absolute timestamps --- .../bioforest/transaction-service.ts | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/services/chain-adapter/bioforest/transaction-service.ts b/src/services/chain-adapter/bioforest/transaction-service.ts index 92adbe9c0..fe44e7ea7 100644 --- a/src/services/chain-adapter/bioforest/transaction-service.ts +++ b/src/services/chain-adapter/bioforest/transaction-service.ts @@ -29,11 +29,41 @@ export class BioforestTransactionService implements ITransactionService { private readonly chainId: string private config: ChainConfig | null = null private baseUrl: string = '' + private beginEpochTime: number | null = null constructor(chainId: string) { this.chainId = chainId } + /** + * Get the genesis block's beginEpochTime (in milliseconds). + * BioForest chain timestamps are relative to this epoch time. + */ + private async getBeginEpochTime(): Promise { + if (this.beginEpochTime !== null) { + return this.beginEpochTime + } + try { + const core = await getBioforestCore(this.chainId) + // Access config through the core's internal structure + // The SDK stores beginEpochTime in milliseconds + const config = (core as unknown as { config?: { beginEpochTime?: number } }).config + this.beginEpochTime = config?.beginEpochTime ?? 0 + } catch { + this.beginEpochTime = 0 + } + return this.beginEpochTime + } + + /** + * Convert BioForest relative timestamp to absolute milliseconds. + * BioForest timestamps are seconds since beginEpochTime. + */ + private async toAbsoluteTimestamp(relativeTimestamp: number): Promise { + const epochTime = await this.getBeginEpochTime() + return relativeTimestamp * 1000 + epochTime + } + private getConfig(): ChainConfig { if (!this.config) { const config = chainConfigService.getConfig(this.chainId) @@ -321,7 +351,7 @@ export class BioforestTransactionService implements ITransactionService { confirmations: 1, requiredConfirmations: 1, }, - timestamp: tx.timestamp * 1000, // Convert to milliseconds + timestamp: await this.toAbsoluteTimestamp(tx.timestamp), blockNumber: BigInt(item.height), type: 'transfer', } @@ -412,6 +442,9 @@ export class BioforestTransactionService implements ITransactionService { const { decimals, symbol } = config + // Get beginEpochTime once for all transactions + const beginEpochTime = await this.getBeginEpochTime() + // Show all transaction types for the address return transactions .map((item) => { @@ -437,7 +470,7 @@ export class BioforestTransactionService implements ITransactionService { confirmations: 1, requiredConfirmations: 1, }, - timestamp: tx.timestamp * 1000, // Convert to milliseconds + timestamp: tx.timestamp * 1000 + beginEpochTime, blockNumber: BigInt(item.height), type: 'transfer' as const, rawType: txType, // Pass the original chain transaction type From f97d0ecfa45596d7e6693751877d8d835cbe9120 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 14:29:27 +0800 Subject: [PATCH 02/11] feat(agent-flow): add label management with auto-creation support - Add top-level await to load labels from GitHub at startup - Add getLabels() to list all available labels with colors - Add createLabel() and ensureLabels() for label management - Add --create-labels flag to auto-create missing labels - Add --list-labels flag to display available labels grouped by prefix - Update createIssue/createPr to validate labels before creation Usage: pnpm agent task start --list-labels pnpm agent task start --type service --title 'Feature' --create-labels --- scripts/agent-flow/mcps/git-workflow.mcp.ts | 116 +++++++++++++++++- scripts/agent-flow/workflows/task.workflow.ts | 38 +++++- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/scripts/agent-flow/mcps/git-workflow.mcp.ts b/scripts/agent-flow/mcps/git-workflow.mcp.ts index 396e4d544..914aff77a 100755 --- a/scripts/agent-flow/mcps/git-workflow.mcp.ts +++ b/scripts/agent-flow/mcps/git-workflow.mcp.ts @@ -46,8 +46,107 @@ function safeExec(cmd: string, cwd?: string): string | null { // Pure Functions (Exports) // ============================================================================= -export async function createIssue(args: { title: string; body: string; labels?: string[]; assignee?: string }) { - const { title, body, labels, assignee = "@me" } = args; +/** Label info type */ +interface LabelInfo { + name: string; + color: string; + description: string; +} + +/** Cache for available labels with their colors */ +let labelsCache: LabelInfo[] | null = null; + +/** + * Load labels from GitHub (called once at startup via top-level await) + */ +async function loadLabelsFromGitHub(): Promise { + const output = safeExec(`gh label list --repo ${REPO} --limit 100 --json name,color,description`); + if (!output) return []; + try { + return JSON.parse(output) as LabelInfo[]; + } catch { + return []; + } +} + +// Top-level await: load labels at module initialization +labelsCache = await loadLabelsFromGitHub(); +console.log(`📋 Loaded ${labelsCache.length} labels from GitHub`); + +/** + * Get all available labels in the repository + */ +export async function getLabels(args?: { refresh?: boolean }): Promise<{ labels: LabelInfo[] }> { + if (args?.refresh || !labelsCache) { + labelsCache = await loadLabelsFromGitHub(); + } + return { labels: labelsCache }; +} + +/** + * Get color for a label (from cache or generate based on prefix) + */ +function getLabelColor(name: string): string { + // Check if label exists in cache + const existing = labelsCache?.find(l => l.name === name); + if (existing) return existing.color; + + // Generate color based on prefix + if (name.startsWith("area/")) return "c5def5"; + if (name.startsWith("type/")) return "0e8a16"; + if (name.startsWith("priority/")) return "d93f0b"; + return "ededed"; // Default gray +} + +/** + * Create a label if it doesn't exist + */ +export async function createLabel(args: { name: string; description?: string; color?: string }): Promise<{ created: boolean }> { + const { name, description = "", color = getLabelColor(name) } = args; + const { labels } = await getLabels(); + + if (labels.some(l => l.name === name)) { + return { created: false }; + } + + const descFlag = description ? `--description "${description}"` : ""; + exec(`gh label create "${name}" --repo ${REPO} --color "${color}" ${descFlag}`); + labelsCache = null; // Invalidate cache + return { created: true }; +} + +/** + * Ensure all specified labels exist, creating missing ones if requested + */ +export async function ensureLabels(args: { labels: string[]; create?: boolean }): Promise<{ missing: string[]; created: string[] }> { + const { labels: requestedLabels, create = false } = args; + const { labels: existingLabels } = await getLabels(); + const existingNames = existingLabels.map(l => l.name); + + const missing = requestedLabels.filter(l => !existingNames.includes(l)); + const created: string[] = []; + + if (create && missing.length > 0) { + for (const label of missing) { + await createLabel({ name: label }); + created.push(label); + } + } + + return { missing: create ? [] : missing, created }; +} + +export async function createIssue(args: { title: string; body: string; labels?: string[]; assignee?: string; createLabels?: boolean }) { + const { title, body, labels, assignee = "@me", createLabels = false } = args; + + // Ensure labels exist before creating issue + if (labels && labels.length > 0) { + const { missing } = await ensureLabels({ labels, create: createLabels }); + if (missing.length > 0) { + throw new Error(`Labels not found: ${missing.join(", ")}. Use --create-labels to auto-create them.`); + } + } + const labelFlag = labels ? labels.map(l => `--label "${l}"`).join(" ") : ""; const assigneeFlag = assignee ? `--assignee "${assignee}"` : ""; const safeBody = body.replace(/"/g, '\\"'); @@ -70,8 +169,17 @@ export async function updateIssue(args: { issueId: string; body?: string; state? return { success: true }; } -export async function createPr(args: { title: string; body: string; head: string; base?: string; draft?: boolean; labels?: string[] }) { - const { title, body, head, base = "main", draft = true, labels } = args; +export async function createPr(args: { title: string; body: string; head: string; base?: string; draft?: boolean; labels?: string[]; createLabels?: boolean }) { + const { title, body, head, base = "main", draft = true, labels, createLabels = false } = args; + + // Ensure labels exist before creating PR + if (labels && labels.length > 0) { + const { missing } = await ensureLabels({ labels, create: createLabels }); + if (missing.length > 0) { + throw new Error(`Labels not found: ${missing.join(", ")}. Use --create-labels to auto-create them.`); + } + } + const draftFlag = draft ? "--draft" : ""; const labelFlag = labels ? labels.map(l => `--label "${l}"`).join(" ") : ""; diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index bbdf63d4f..d7ba59633 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -22,6 +22,7 @@ import { createWorktree, pushWorktree, updateIssue, + getLabels, } from "../mcps/git-workflow.mcp.ts"; import { getRelatedChapters } from "../mcps/whitebook.mcp.ts"; @@ -98,15 +99,44 @@ const startWorkflow = defineWorkflow({ name: "start", description: "启动新任务 (Issue -> Branch -> Worktree -> Draft PR)", args: { - title: { type: "string", description: "任务标题", required: true }, + title: { type: "string", description: "任务标题", required: false }, type: { type: "string", description: "任务类型 (ui|service|page|hybrid)", default: "hybrid", }, description: { type: "string", description: "任务描述", required: false }, + "create-labels": { + type: "boolean", + description: "自动创建不存在的标签", + default: false, + }, + "list-labels": { + type: "boolean", + description: "列出所有可用标签", + default: false, + }, }, handler: async (args) => { + // Handle --list-labels flag + if (args["list-labels"]) { + const { labels } = await getLabels({ refresh: true }); + console.log("📋 可用标签列表:\n"); + const grouped = new Map(); + for (const label of labels) { + const prefix = label.name.includes("/") ? label.name.split("/")[0] : "other"; + if (!grouped.has(prefix)) grouped.set(prefix, []); + grouped.get(prefix)!.push(label); + } + for (const [prefix, items] of grouped) { + console.log(` [${prefix}]`); + for (const item of items) { + console.log(` - ${item.name} (#${item.color})${item.description ? ` - ${item.description}` : ""}`); + } + } + return; + } + const title = args.title || args._.join(" "); if (!title) { console.error("❌ 错误: 请提供任务标题"); @@ -114,6 +144,7 @@ const startWorkflow = defineWorkflow({ } const type = (args.type || "hybrid") as keyof typeof TEMPLATES; const rawDesc = args.description || "Start development..."; + const createLabels = args["create-labels"] as boolean; // 1. 组装 Description const template = TEMPLATES[type] || TEMPLATES.hybrid; @@ -125,6 +156,7 @@ const startWorkflow = defineWorkflow({ if (type === "service") labels.push("area/core"); console.log(`🚀 启动任务: ${title} [${type}]\n`); + console.log(`🏷️ 标签: ${labels.join(", ")}${createLabels ? " (自动创建)" : ""}\n`); // 3. 上下文注入 console.log("📚 推荐阅读白皮书章节:"); @@ -138,6 +170,7 @@ const startWorkflow = defineWorkflow({ title, body: description, labels, + createLabels, }); console.log(` ✅ Issue #${issueId} Created: ${issueUrl}`); @@ -168,6 +201,7 @@ const startWorkflow = defineWorkflow({ base: "main", draft: true, labels, + createLabels, }); console.log(` ✅ Draft PR Created: ${prUrl}`); @@ -280,6 +314,8 @@ export const workflow = createRouter({ examples: [ ['task start --type ui --title "Button Component"', "启动 UI 任务"], ['task start --type service --title "Auth Service"', "启动服务任务"], + ['task start --type service --title "New Feature" --create-labels', "启动任务并自动创建缺失标签"], + ['task start --list-labels', "列出所有可用标签"], ['task sync "- [x] Step 1"', "同步进度"], ["task submit", "提交任务"], ], From 2bf22358ca2dd91925a8ff92f8aff448d3a4bacd Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 14:32:06 +0800 Subject: [PATCH 03/11] feat(agent-flow): skip CI on initial draft PR commit Add [skip ci] to initial commit message to avoid triggering CI runners for draft PRs. CI will run normally when task submit is called. --- scripts/agent-flow/workflows/task.workflow.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index d7ba59633..b3f04a470 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -185,11 +185,11 @@ const startWorkflow = defineWorkflow({ console.log(` ✅ Worktree Created: ${path}`); console.log(` ✅ Branch Created: ${branch}`); - // 6. 初始化提交 & 推送 + // 6. 初始化提交 & 推送 (skip CI for draft) console.log("\n3️⃣ 初始化 Git 环境..."); await pushWorktree({ path, - message: `chore: start issue #${issueId}`, + message: `chore: start issue #${issueId} [skip ci]`, }); // 7. 创建 Draft PR From b5b2bc1254cc689ced0b3300506e5a1c28c87666 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 14:34:05 +0800 Subject: [PATCH 04/11] docs(agent-flow): add detailed documentation for workflow and mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document workflow stages (start → develop → submit) - Explain CI skip behavior for draft PRs - Document label management features - Add color inference rules for new labels --- scripts/agent-flow/mcps/git-workflow.mcp.ts | 21 ++++++++++ scripts/agent-flow/workflows/task.workflow.ts | 42 +++++++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/scripts/agent-flow/mcps/git-workflow.mcp.ts b/scripts/agent-flow/mcps/git-workflow.mcp.ts index 914aff77a..2e9ca0f89 100755 --- a/scripts/agent-flow/mcps/git-workflow.mcp.ts +++ b/scripts/agent-flow/mcps/git-workflow.mcp.ts @@ -3,6 +3,27 @@ * Git Workflow MCP - GitHub & Git 操作封装 * * 提供原子化的 Git/GitHub 操作工具,供 workflow 调用。 + * + * ## 标签管理 + * + * 模块加载时通过 top-level await 从 GitHub 获取所有标签: + * - `getLabels()` - 获取标签列表(含 name, color, description) + * - `createLabel()` - 创建新标签(自动推断颜色) + * - `ensureLabels()` - 确保标签存在,可选自动创建 + * + * 颜色推断规则: + * - `area/*` → #c5def5 (蓝色) + * - `type/*` → #0e8a16 (绿色) + * - `priority/*` → #d93f0b (红色) + * - 其他 → #ededed (灰色) + * + * ## Issue/PR 创建 + * + * `createIssue()` 和 `createPr()` 支持: + * - `labels` - 要添加的标签数组 + * - `createLabels` - 是否自动创建缺失的标签 + * + * 如果标签不存在且 `createLabels=false`,会抛出错误提示。 */ import { execSync } from "node:child_process"; diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index b3f04a470..159eedbc4 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -4,10 +4,38 @@ * * 核心理念:AI 的计划即 Issue,AI 的执行即 PR。 * - * 主要功能: - * 1. start: 一键启动 (Issue -> Branch -> Worktree -> Draft PR) - * 2. sync: 同步进度 (Local Todo -> Issue Body) - * 3. submit: 提交任务 (Push -> Ready PR) + * ## 工作流程 + * + * ``` + * task start task submit + * │ │ + * ▼ ▼ + * Issue + Branch + Worktree Push + Ready PR + * + Draft PR [skip ci] (触发 CI) + * ``` + * + * ## 主要功能 + * + * 1. **start**: 一键启动开发环境 + * - 创建 GitHub Issue (根据 type 选择模板) + * - 创建 Git Branch + Worktree + * - 创建 Draft PR (带 [skip ci],不触发 CI) + * - 支持 --list-labels 列出可用标签 + * - 支持 --create-labels 自动创建缺失标签 + * + * 2. **sync**: 同步进度到 Issue + * - 将本地 Todo/进度更新到 Issue Description + * + * 3. **submit**: 提交任务触发 CI + * - 推送代码 (不带 [skip ci],触发 CI) + * - 标记 PR 为 Ready for Review + * + * ## 标签管理 + * + * 标签在模块加载时从 GitHub 动态获取,支持: + * - 按前缀分组显示 (type/, area/, etc.) + * - 自动推断新标签颜色 + * - 创建前验证标签是否存在 */ import { existsSync } from "jsr:@std/fs"; @@ -97,7 +125,7 @@ ${desc} */ const startWorkflow = defineWorkflow({ name: "start", - description: "启动新任务 (Issue -> Branch -> Worktree -> Draft PR)", + description: "启动新任务 (Issue + Worktree + Draft PR,不触发 CI)", args: { title: { type: "string", description: "任务标题", required: false }, type: { @@ -250,11 +278,11 @@ const syncWorkflow = defineWorkflow({ /** * 提交任务 - * Push 代码 -> 标记 PR 为 Ready + * Push 代码 (触发 CI) -> 标记 PR 为 Ready */ const submitWorkflow = defineWorkflow({ name: "submit", - description: "提交任务 (Push -> Ready PR)", + description: "提交任务并触发 CI (Push + Ready PR)", handler: async () => { const wt = getCurrentWorktreeInfo(); if (!wt || !wt.path) { From f8eb97e693757bdcdcfeee1f232253f179ff8c0d Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 14:40:18 +0800 Subject: [PATCH 05/11] refactor(flow): rename workflow tool to 'use' Rename the MCP tool from 'workflow' to 'use' for cleaner API: - workflow___workflow -> workflow___use - Update examples in dynamic description --- packages/flow/src/meta/meta.mcp.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/flow/src/meta/meta.mcp.ts b/packages/flow/src/meta/meta.mcp.ts index 4f49ff84a..28dac9e08 100644 --- a/packages/flow/src/meta/meta.mcp.ts +++ b/packages/flow/src/meta/meta.mcp.ts @@ -3,7 +3,7 @@ * Meta MCP Server * * Provides workflow execution capability to AI agents. - * - workflow(name, args): Execute any workflow + * - use(name, args): Execute any workflow by name * - reload(): Refresh workflow list and return updated description * - buildMetaMcp(): Package workflows into an MCP server * @@ -185,7 +185,7 @@ async function buildToolDescription( .join("\n"); // 动态生成 examples - const examples = workflows.slice(0, 3).map((w) => ` workflow("${w.name}", ["--help"])`).join("\n"); + const examples = workflows.slice(0, 3).map((w) => ` use("${w.name}", ["--help"])`).join("\n"); return `Execute a workflow by name with arguments. @@ -197,7 +197,7 @@ async function buildToolDescription( ${workflowList || "(none)"} ## Examples -${examples || ' workflow("", ["--help"])'}`; +${examples || ' use("", ["--help"])'}`; } // ============================================================================= @@ -341,7 +341,7 @@ async function createWorkflowTool( const description = await buildToolDescription(directories, filter); return defineTool({ - name: "workflow", + name: "use", description, inputSchema: z.object({ name: z From 37642bc27a46bbcd0ebc4cd64d859948c6733f97 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 14:55:06 +0800 Subject: [PATCH 06/11] feat(flow): add RuntimeContext and async description support - Add RuntimeContext to detect MCP vs CLI mode - Support async description: string | () => string | () => Promise - Add formatScenarios() and formatDataSection() mode-aware formatters - MCP mode set automatically in buildToolDescription and executeWorkflow Usage example: description: async () => { const labels = await getLabels(); return `Task management\n\n${formatScenarios('task', scenarios)}\n\n${formatDataSection('Labels', labels)}`; } --- packages/flow/src/common/async-context.ts | 101 ++++++++++++++++++ .../flow/src/common/workflow/base-workflow.ts | 24 ++++- packages/flow/src/meta/meta.mcp.ts | 40 +++---- 3 files changed, 143 insertions(+), 22 deletions(-) diff --git a/packages/flow/src/common/async-context.ts b/packages/flow/src/common/async-context.ts index 18e29277e..6b60d12f4 100644 --- a/packages/flow/src/common/async-context.ts +++ b/packages/flow/src/common/async-context.ts @@ -85,6 +85,107 @@ export const PreferencesContext = new AsyncContext( "PreferencesContext" ); +// ============================================================================= +// Runtime Context (MCP vs CLI) +// ============================================================================= + +export type RuntimeMode = "mcp" | "cli"; + +export interface RuntimeInfo { + mode: RuntimeMode; +} + +/** + * Async context for runtime mode + */ +export const RuntimeContext = new AsyncContext("RuntimeContext"); + +/** + * Get current runtime mode, defaults to "cli" + */ +export function getRuntimeMode(): RuntimeMode { + return RuntimeContext.tryGet()?.mode ?? "cli"; +} + +/** + * Check if running in MCP mode + */ +export function isMcpMode(): boolean { + return getRuntimeMode() === "mcp"; +} + +/** + * Check if running in CLI mode + */ +export function isCliMode(): boolean { + return getRuntimeMode() === "cli"; +} + +/** + * Run function within specified runtime mode + */ +export function withRuntimeMode(mode: RuntimeMode, fn: () => R): R { + return RuntimeContext.run({ mode }, fn); +} + +// ============================================================================= +// Scenario Formatting (mode-aware) +// ============================================================================= + +export interface Scenario { + when: string; + args?: string[]; +} + +/** + * Format scenarios based on current runtime mode + * - MCP: `when → use("workflow", ["arg1", "arg2"])` + * - CLI: `when:\n workflow arg1 arg2` + */ +export function formatScenarios( + workflowName: string, + scenarios: Scenario[] +): string { + const mode = getRuntimeMode(); + + if (mode === "mcp") { + return scenarios + .map((s) => `- ${s.when} → use("${workflowName}", ${JSON.stringify(s.args ?? [])})`) + .join("\n"); + } else { + return scenarios + .map((s) => ` ${s.when}:\n ${workflowName} ${s.args?.join(" ") ?? ""}`) + .join("\n"); + } +} + +/** + * Format a key-value data section + * - MCP: compact inline format + * - CLI: indented list format + */ +export function formatDataSection( + title: string, + items: Array<{ name: string; description?: string; color?: string }> +): string { + const mode = getRuntimeMode(); + + if (mode === "mcp") { + // Compact format for MCP + return `${title}: ${items.map((i) => i.name).join(", ")}`; + } else { + // Detailed format for CLI + const lines = [`${title}:`]; + for (const item of items) { + const parts = [item.name]; + if (item.color) parts.push(`#${item.color}`); + if (item.description) parts.push(`- ${item.description}`); + lines.push(` ${parts.join(" ")}`); + } + return lines.join("\n"); + } +} + // ============================================================================= // Helpers // ============================================================================= diff --git a/packages/flow/src/common/workflow/base-workflow.ts b/packages/flow/src/common/workflow/base-workflow.ts index 75e9d49f1..33f38f447 100644 --- a/packages/flow/src/common/workflow/base-workflow.ts +++ b/packages/flow/src/common/workflow/base-workflow.ts @@ -62,9 +62,19 @@ async function buildSubflowMap(subflows: SubflowDef[]): Promise string) | (() => Promise); + +/** Resolve description to string */ +export async function resolveDescription(desc: DescriptionProvider): Promise { + if (typeof desc === "string") return desc; + const result = desc(); + return result instanceof Promise ? await result : result; +} + export interface WorkflowConfig> { name: string; - description: string; + description: DescriptionProvider; version?: string; args?: TArgs; subflows?: SubflowDef[]; @@ -76,11 +86,16 @@ export interface WorkflowConfig> { export interface WorkflowMeta { name: string; - description: string; + description: DescriptionProvider; version: string; args: Record; } +/** Get resolved description string from WorkflowMeta */ +export async function getMetaDescription(meta: WorkflowMeta): Promise { + return resolveDescription(meta.description); +} + export interface WorkflowContext { meta: WorkflowMeta; path: string[]; @@ -241,12 +256,13 @@ async function printHelp>( } opts.printed.add(id); + const description = await getMetaDescription(meta); if (opts.indent === 0) { - console.log(`${meta.name} v${meta.version} - ${meta.description}`); + console.log(`${meta.name} v${meta.version} - ${description}`); console.log(); console.log(`Usage: ${pathStr || meta.name} [subflow...] [options]`); } else { - console.log(`${prefix}${meta.name} - ${meta.description}`); + console.log(`${prefix}${meta.name} - ${description}`); } const argEntries = Object.entries(meta.args); diff --git a/packages/flow/src/meta/meta.mcp.ts b/packages/flow/src/meta/meta.mcp.ts index 28dac9e08..7d019c1ed 100644 --- a/packages/flow/src/meta/meta.mcp.ts +++ b/packages/flow/src/meta/meta.mcp.ts @@ -23,6 +23,7 @@ import { printMcpHelp, z, } from "../common/mcp/base-mcp.ts"; +import { withRuntimeMode } from "../common/async-context.ts"; const MCP_NAME = "meta"; @@ -168,26 +169,28 @@ async function buildToolDescription( directories: string[], filter?: string[] ): Promise { - let workflows = await scanWorkflows(directories); + // Run in MCP mode to get MCP-formatted descriptions + return withRuntimeMode("mcp", async () => { + let workflows = await scanWorkflows(directories); - if (filter && filter.length > 0) { - workflows = workflows.filter((w) => filter.includes(w.name)); - } + if (filter && filter.length > 0) { + workflows = workflows.filter((w) => filter.includes(w.name)); + } - const workflowList = workflows - .map((w) => { - const tags: string[] = []; - if (w.mode) tags.push(w.mode); - if (w.isUserOverride) tags.push("user"); - const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : ""; - return `- ${w.name}: ${w.description}${tagStr}`; - }) - .join("\n"); + const workflowList = workflows + .map((w) => { + const tags: string[] = []; + if (w.mode) tags.push(w.mode); + if (w.isUserOverride) tags.push("user"); + const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : ""; + return `- ${w.name}: ${w.description}${tagStr}`; + }) + .join("\n"); - // 动态生成 examples - const examples = workflows.slice(0, 3).map((w) => ` use("${w.name}", ["--help"])`).join("\n"); + // 动态生成 examples + const examples = workflows.slice(0, 3).map((w) => ` use("${w.name}", ["--help"])`).join("\n"); - return `Execute a workflow by name with arguments. + return `Execute a workflow by name with arguments. ## Usage - Use \`--help\` to get detailed usage for any workflow @@ -198,6 +201,7 @@ ${workflowList || "(none)"} ## Examples ${examples || ' use("", ["--help"])'}`; + }); } // ============================================================================= @@ -295,8 +299,8 @@ async function executeWorkflow( }; } - // Execute with args - await workflow.run(args); + // Execute with args in MCP mode + await withRuntimeMode("mcp", () => workflow.run(args)); return { success: true, From 9c9092978948260705ffff4f1ad5f1f9e7eeb996 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 15:00:17 +0800 Subject: [PATCH 07/11] feat(destroy): add asset destroy feature for BioForest chains - Add AssetSelector component for selecting transfer/destroy assets - Add TokenItem renderActions slot for custom action menus - Create useBurn hook with bioforest-specific destroy logic - Add DestroyPage with confirmation flow - Enhance SendPage with AssetSelector for non-native assets - Add WalletTab token menu (transfer/destroy options) - Add bio_destroyAsset miniapp ecosystem handler - Add MiniappDestroyConfirmJob sheet - Add i18n translations (zh-CN, en) - Add unit tests (25 new tests passed) --- src/apis/bnqkl_wallet/bioforest/types.ts | 2 + .../asset/asset-selector.stories.tsx | 161 ++++++++ src/components/asset/asset-selector.test.tsx | 213 ++++++++++ src/components/asset/asset-selector.tsx | 163 ++++++++ src/components/asset/index.ts | 2 + src/components/token/index.ts | 2 +- src/components/token/token-item.tsx | 67 ++- src/components/token/token-list.tsx | 10 +- .../wallet/wallet-address-portfolio-view.tsx | 10 +- src/hooks/use-burn.bioforest.ts | 202 +++++++++ src/hooks/use-burn.test.ts | 278 +++++++++++++ src/hooks/use-burn.ts | 383 ++++++++++++++++++ src/hooks/use-burn.types.ts | 84 ++++ src/i18n/locales/en/home.json | 5 +- src/i18n/locales/en/transaction.json | 14 +- src/i18n/locales/zh-CN/home.json | 5 +- src/i18n/locales/zh-CN/transaction.json | 14 +- src/pages/destroy/destroy.stories.tsx | 48 +++ src/pages/destroy/index.tsx | 365 +++++++++++++++++ src/pages/send/index.tsx | 82 +++- src/services/bioforest-sdk/index.ts | 149 +++++++ src/services/ecosystem/handlers/context.ts | 3 +- src/services/ecosystem/handlers/destroy.ts | 53 +++ src/services/ecosystem/handlers/index.ts | 5 + src/services/ecosystem/provider.ts | 6 + src/services/ecosystem/types.ts | 14 + src/stackflow/activities/DestroyActivity.tsx | 22 + src/stackflow/activities/SendActivity.tsx | 2 + .../sheets/MiniappDestroyConfirmJob.tsx | 240 +++++++++++ src/stackflow/activities/sheets/index.ts | 1 + src/stackflow/activities/tabs/WalletTab.tsx | 64 ++- src/stackflow/stackflow.ts | 6 + 32 files changed, 2657 insertions(+), 18 deletions(-) create mode 100644 src/components/asset/asset-selector.stories.tsx create mode 100644 src/components/asset/asset-selector.test.tsx create mode 100644 src/components/asset/asset-selector.tsx create mode 100644 src/hooks/use-burn.bioforest.ts create mode 100644 src/hooks/use-burn.test.ts create mode 100644 src/hooks/use-burn.ts create mode 100644 src/hooks/use-burn.types.ts create mode 100644 src/pages/destroy/destroy.stories.tsx create mode 100644 src/pages/destroy/index.tsx create mode 100644 src/services/ecosystem/handlers/destroy.ts create mode 100644 src/stackflow/activities/DestroyActivity.tsx create mode 100644 src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx diff --git a/src/apis/bnqkl_wallet/bioforest/types.ts b/src/apis/bnqkl_wallet/bioforest/types.ts index e944c99be..83f195b0b 100644 --- a/src/apis/bnqkl_wallet/bioforest/types.ts +++ b/src/apis/bnqkl_wallet/bioforest/types.ts @@ -90,6 +90,8 @@ export interface TokenDetailResult { decimals: number logoUrl?: string iconUrl?: string + /** Asset issuer address (used as recipient for destroy transactions) */ + applyAddress: string } export interface BroadcastResult { diff --git a/src/components/asset/asset-selector.stories.tsx b/src/components/asset/asset-selector.stories.tsx new file mode 100644 index 000000000..e0df31586 --- /dev/null +++ b/src/components/asset/asset-selector.stories.tsx @@ -0,0 +1,161 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { AssetSelector } from './asset-selector' +import type { TokenInfo } from '@/components/token/token-item' + +const mockAssets: TokenInfo[] = [ + { + symbol: 'BFM', + name: 'BFMeta', + balance: '10000.12345678', + decimals: 8, + chain: 'bfmeta', + }, + { + symbol: 'USDT', + name: 'Tether USD', + balance: '500.00', + decimals: 6, + chain: 'bfmeta', + }, + { + symbol: 'ETH', + name: 'Ethereum', + balance: '2.50000000', + decimals: 18, + chain: 'ethereum', + }, + { + symbol: 'BTC', + name: 'Bitcoin', + balance: '0.05', + decimals: 8, + chain: 'bitcoin', + }, + { + symbol: 'TRX', + name: 'Tron', + balance: '1500.00', + decimals: 6, + chain: 'tron', + }, +] + +const meta: Meta = { + title: 'Asset/AssetSelector', + component: AssetSelector, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +/** 交互式示例 */ +function InteractiveDemo() { + const [selected, setSelected] = useState(null) + + return ( +
+ + {selected && ( +
+ 已选择: {selected.symbol} ({selected.name}) +
+ )} +
+ ) +} + +export const Default: Story = { + render: () => , +} + +/** 预选资产 */ +export const WithSelectedAsset: Story = { + args: { + selectedAsset: mockAssets[0], + assets: mockAssets, + onSelect: () => {}, + }, +} + +/** 禁用状态 */ +export const Disabled: Story = { + args: { + selectedAsset: mockAssets[1], + assets: mockAssets, + onSelect: () => {}, + disabled: true, + }, +} + +/** 排除资产 (如销毁场景不显示主资产) */ +export const ExcludeAssets: Story = { + render: () => { + const [selected, setSelected] = useState(null) + return ( +
+

排除了 BFM (主资产)

+ +
+ ) + }, +} + +/** 不显示余额 */ +export const WithoutBalance: Story = { + args: { + selectedAsset: mockAssets[0], + assets: mockAssets, + onSelect: () => {}, + showBalance: false, + }, +} + +/** 自定义占位符 */ +export const CustomPlaceholder: Story = { + args: { + selectedAsset: null, + assets: mockAssets, + onSelect: () => {}, + placeholder: '点击选择要销毁的资产', + }, +} + +/** 空资产列表 */ +export const EmptyAssets: Story = { + args: { + selectedAsset: null, + assets: [], + onSelect: () => {}, + }, +} + +/** 所有资产被排除 */ +export const AllExcluded: Story = { + args: { + selectedAsset: null, + assets: mockAssets, + onSelect: () => {}, + excludeAssets: ['BFM', 'USDT', 'ETH', 'BTC', 'TRX'], + }, +} diff --git a/src/components/asset/asset-selector.test.tsx b/src/components/asset/asset-selector.test.tsx new file mode 100644 index 000000000..ff41ce9c7 --- /dev/null +++ b/src/components/asset/asset-selector.test.tsx @@ -0,0 +1,213 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { AssetSelector } from './asset-selector' +import type { TokenInfo } from '@/components/token/token-item' + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, defaultValue?: string) => defaultValue ?? key, + }), +})) + +const mockAssets: TokenInfo[] = [ + { + symbol: 'BFM', + name: 'BFMeta', + balance: '1000.00', + decimals: 8, + chain: 'bfmeta', + }, + { + symbol: 'USDT', + name: 'Tether USD', + balance: '500.00', + decimals: 6, + chain: 'bfmeta', + }, + { + symbol: 'ETH', + name: 'Ethereum', + balance: '2.5', + decimals: 18, + chain: 'bfmeta', + }, +] + +describe('AssetSelector', () => { + it('renders placeholder when no asset selected', () => { + render( + , + ) + + expect(screen.getByText('选择资产')).toBeInTheDocument() + }) + + it('renders selected asset', () => { + render( + , + ) + + expect(screen.getByText('BFM')).toBeInTheDocument() + }) + + it('opens sheet on click', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('asset-selector')) + + // Sheet should open and show assets + expect(screen.getByText('BFMeta')).toBeInTheDocument() + expect(screen.getByText('Tether USD')).toBeInTheDocument() + }) + + it('calls onSelect when asset is clicked', () => { + const onSelect = vi.fn() + render( + , + ) + + // Open sheet + fireEvent.click(screen.getByTestId('asset-selector')) + + // Click on USDT + fireEvent.click(screen.getByText('Tether USD')) + + expect(onSelect).toHaveBeenCalledWith(mockAssets[1]) + }) + + it('excludes assets from list', () => { + render( + , + ) + + // Open sheet + fireEvent.click(screen.getByTestId('asset-selector')) + + // Only ETH should be visible + expect(screen.queryByText('BFMeta')).not.toBeInTheDocument() + expect(screen.queryByText('Tether USD')).not.toBeInTheDocument() + expect(screen.getByText('Ethereum')).toBeInTheDocument() + }) + + it('shows empty state when all assets excluded', () => { + render( + , + ) + + // Open sheet + fireEvent.click(screen.getByTestId('asset-selector')) + + expect(screen.getByText('暂无可选资产')).toBeInTheDocument() + }) + + it('is disabled when disabled prop is true', () => { + render( + , + ) + + const trigger = screen.getByTestId('asset-selector') + expect(trigger).toBeDisabled() + }) + + it('shows balance when showBalance is true', () => { + render( + , + ) + + expect(screen.getByText('余额:')).toBeInTheDocument() + }) + + it('hides balance when showBalance is false', () => { + render( + , + ) + + expect(screen.queryByText('余额:')).not.toBeInTheDocument() + }) + + it('uses custom placeholder', () => { + render( + , + ) + + expect(screen.getByText('Pick an asset')).toBeInTheDocument() + }) + + it('highlights selected asset in list', () => { + render( + , + ) + + // Open sheet + fireEvent.click(screen.getByTestId('asset-selector')) + + // The selected asset (USDT) should have a different background style + // We can verify the sheet is open and shows assets + expect(screen.getByText('Tether USD')).toBeInTheDocument() + expect(screen.getByText('BFMeta')).toBeInTheDocument() + }) +}) diff --git a/src/components/asset/asset-selector.tsx b/src/components/asset/asset-selector.tsx new file mode 100644 index 000000000..a4d974bf0 --- /dev/null +++ b/src/components/asset/asset-selector.tsx @@ -0,0 +1,163 @@ +/** + * AssetSelector - 资产选择器组件 + * + * 用于在转账、销毁等场景中选择资产 + */ + +import { useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { TokenIcon } from '@/components/wallet/token-icon' +import { AmountDisplay } from '@/components/common' +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { IconChevronDown, IconCheck } from '@tabler/icons-react' +import type { TokenInfo } from '@/components/token/token-item' + +export interface AssetSelectorProps { + /** 当前选中的资产 */ + selectedAsset: TokenInfo | null + /** 可选资产列表 */ + assets: TokenInfo[] + /** 选择资产回调 */ + onSelect: (asset: TokenInfo) => void + /** 是否禁用 */ + disabled?: boolean + /** 排除的资产符号列表 (如主资产不可销毁) */ + excludeAssets?: string[] + /** 是否显示余额 */ + showBalance?: boolean + /** 占位文本 */ + placeholder?: string + /** 额外的 class */ + className?: string + /** 测试 ID */ + testId?: string +} + +/** + * 资产选择器 + * + * 点击展开 Sheet 显示可选资产列表 + */ +export function AssetSelector({ + selectedAsset, + assets, + onSelect, + disabled = false, + excludeAssets = [], + showBalance = true, + placeholder, + className, + testId, +}: AssetSelectorProps) { + const { t } = useTranslation('transaction') + const [open, setOpen] = useState(false) + + // 过滤可选资产 + const availableAssets = useMemo(() => { + if (excludeAssets.length === 0) return assets + const excludeSet = new Set(excludeAssets.map((s) => s.toUpperCase())) + return assets.filter((asset) => !excludeSet.has(asset.symbol.toUpperCase())) + }, [assets, excludeAssets]) + + const handleSelect = (asset: TokenInfo) => { + onSelect(asset) + setOpen(false) + } + + const displayPlaceholder = placeholder ?? t('assetSelector.selectAsset', '选择资产') + + return ( + + + {selectedAsset ? ( +
+ +
+ {selectedAsset.symbol} + {showBalance && ( + + {t('assetSelector.balance', '余额')}: + + )} +
+
+ ) : ( + {displayPlaceholder} + )} + +
+ + + + {t('assetSelector.selectAsset', '选择资产')} + + +
+ {availableAssets.length === 0 ? ( +
+

{t('assetSelector.noAssets', '暂无可选资产')}

+
+ ) : ( +
+ {availableAssets.map((asset) => { + const isSelected = selectedAsset?.symbol === asset.symbol && selectedAsset?.chain === asset.chain + return ( + + ) + })} +
+ )} +
+ + {/* Safe area */} +
+ + + ) +} diff --git a/src/components/asset/index.ts b/src/components/asset/index.ts index a86f32688..657d51344 100644 --- a/src/components/asset/index.ts +++ b/src/components/asset/index.ts @@ -1,2 +1,4 @@ export { AssetItem } from './asset-item' export { AssetList } from './asset-list' +export { AssetSelector } from './asset-selector' +export type { AssetSelectorProps } from './asset-selector' diff --git a/src/components/token/index.ts b/src/components/token/index.ts index 2c3bb29ce..59ec5d92b 100644 --- a/src/components/token/index.ts +++ b/src/components/token/index.ts @@ -1,3 +1,3 @@ export { BalanceDisplay, formatNumber } from './balance-display' -export { TokenItem, type TokenInfo } from './token-item' +export { TokenItem, type TokenInfo, type TokenItemContext } from './token-item' export { TokenList } from './token-list' diff --git a/src/components/token/token-item.tsx b/src/components/token/token-item.tsx index 7956c6677..a7545dd12 100644 --- a/src/components/token/token-item.tsx +++ b/src/components/token/token-item.tsx @@ -25,6 +25,18 @@ export interface TokenInfo { change24h?: number | undefined; } +/** Context passed to renderActions for conditional rendering */ +export interface TokenItemContext { + /** Chain type of the token */ + chainType: ChainType; + /** Whether this is a BioForest chain (supports destroy) */ + isBioforestChain: boolean; + /** Whether this is the main/native asset of the chain */ + isMainAsset: boolean; + /** Whether this asset can be destroyed (bioforest + non-main asset) */ + canDestroy: boolean; +} + interface TokenItemProps { token: TokenInfo; onClick?: (() => void) | undefined; @@ -33,13 +45,55 @@ interface TokenItemProps { loading?: boolean | undefined; className?: string | undefined; testId?: string | undefined; + /** + * Render prop for custom actions (e.g., dropdown menu) + * Receives the token and context for conditional rendering + */ + renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined; + /** Main asset symbol of the chain (used to determine isMainAsset) */ + mainAssetSymbol?: string | undefined; } -export function TokenItem({ token, onClick, showChange = false, loading = false, className, testId }: TokenItemProps) { +// BioForest chain types that support asset destruction +const BIOFOREST_CHAINS = new Set([ + 'bfmeta', + 'ccchain', + 'pmchain', + 'bfchainv2', + 'btgmeta', + 'biwmeta', + 'ethmeta', + 'malibu', +]); + +export function TokenItem({ + token, + onClick, + showChange = false, + loading = false, + className, + testId, + renderActions, + mainAssetSymbol, +}: TokenItemProps) { const isClickable = !!onClick; const { t } = useTranslation(['currency', 'common']); const currency = useCurrency(); + // Compute context for renderActions + const isBioforestChain = BIOFOREST_CHAINS.has(token.chain); + const isMainAsset = mainAssetSymbol + ? token.symbol.toUpperCase() === mainAssetSymbol.toUpperCase() + : false; + const canDestroy = isBioforestChain && !isMainAsset; + + const context: TokenItemContext = { + chainType: token.chain, + isBioforestChain, + isMainAsset, + canDestroy, + }; + const shouldFetchRate = token.fiatValue !== undefined && currency !== 'USD'; const { data: exchangeRateData, @@ -127,6 +181,17 @@ export function TokenItem({ token, onClick, showChange = false, loading = false,

)}
+ + {/* Custom actions slot */} + {renderActions && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {renderActions(token, context)} +
+ )} ); } diff --git a/src/components/token/token-list.tsx b/src/components/token/token-list.tsx index 2d45b2951..f6e413e70 100644 --- a/src/components/token/token-list.tsx +++ b/src/components/token/token-list.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils' -import { TokenItem, type TokenInfo } from './token-item' +import { TokenItem, type TokenInfo, type TokenItemContext } from './token-item' import { EmptyState } from '../common/empty-state' import { SkeletonList } from '../common/skeleton' @@ -16,6 +16,10 @@ interface TokenListProps { emptyAction?: React.ReactNode | undefined className?: string | undefined testId?: string | undefined + /** Render prop for custom actions per token item */ + renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined + /** Main asset symbol for the chain (used by renderActions context) */ + mainAssetSymbol?: string | undefined } export function TokenList({ @@ -29,6 +33,8 @@ export function TokenList({ emptyAction, className, testId, + renderActions, + mainAssetSymbol, }: TokenListProps) { if (loading) { return @@ -60,6 +66,8 @@ export function TokenList({ testId={testId ? `token-item-${token.chain}-${token.symbol}` : undefined} showChange={showChange} loading={refreshing} + renderActions={renderActions} + mainAssetSymbol={mainAssetSymbol} {...(onTokenClick && { onClick: () => onTokenClick(token) })} /> ))} diff --git a/src/components/wallet/wallet-address-portfolio-view.tsx b/src/components/wallet/wallet-address-portfolio-view.tsx index 77b47d8a7..b36c59d41 100644 --- a/src/components/wallet/wallet-address-portfolio-view.tsx +++ b/src/components/wallet/wallet-address-portfolio-view.tsx @@ -4,7 +4,7 @@ import { TokenList } from '@/components/token/token-list' import { TransactionList } from '@/components/transaction/transaction-list' import { SwipeableTabs } from '@/components/layout/swipeable-tabs' import { ProviderFallbackWarning } from '@/components/common/provider-fallback-warning' -import type { TokenInfo } from '@/components/token/token-item' +import type { TokenInfo, TokenItemContext } from '@/components/token/token-item' import type { TransactionInfo } from '@/components/transaction/transaction-item' import type { ChainType } from '@/stores' @@ -26,6 +26,10 @@ export interface WalletAddressPortfolioViewProps { onTransactionClick?: (tx: TransactionInfo) => void /** 渲染交易列表底部额外内容(如"查看全部"按钮) */ renderTransactionFooter?: () => React.ReactNode + /** Main asset symbol for the chain (used for renderActions context) */ + mainAssetSymbol?: string + /** Render prop for token item actions (e.g., dropdown menu) */ + renderTokenActions?: (token: TokenInfo, context: TokenItemContext) => React.ReactNode className?: string testId?: string } @@ -45,6 +49,8 @@ export function WalletAddressPortfolioView({ onTokenClick, onTransactionClick, renderTransactionFooter, + mainAssetSymbol, + renderTokenActions, className, testId = 'wallet-address-portfolio', }: WalletAddressPortfolioViewProps) { @@ -77,6 +83,8 @@ export function WalletAddressPortfolioView({ onTokenClick={onTokenClick} emptyTitle={t('home:wallet.noAssets')} emptyDescription={t('home:wallet.noAssetsOnChain', { chain: displayChainName })} + mainAssetSymbol={mainAssetSymbol} + renderActions={renderTokenActions} testId={`${testId}-token-list`} /> diff --git a/src/hooks/use-burn.bioforest.ts b/src/hooks/use-burn.bioforest.ts new file mode 100644 index 000000000..fcad7afd6 --- /dev/null +++ b/src/hooks/use-burn.bioforest.ts @@ -0,0 +1,202 @@ +/** + * BioForest chain-specific burn (destroy asset) logic + */ + +import type { ChainConfig } from '@/services/chain-config' +import { Amount } from '@/types/amount' +import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' +import { + createDestroyTransaction, + broadcastTransaction, + getAddressInfo, + getAssetDetail, + getDestroyTransactionMinFee, + verifyTwoStepSecret, +} from '@/services/bioforest-sdk' + +export interface BioforestBurnFeeResult { + amount: Amount + symbol: string +} + +function getBioforestApiUrl(chainConfig: ChainConfig): string | null { + const biowallet = chainConfig.apis.find((p) => p.type === 'biowallet-v1') + return biowallet?.endpoint ?? null +} + +/** + * Fetch the asset's applyAddress (issuer address) for destroy transaction + */ +export async function fetchAssetApplyAddress( + chainConfig: ChainConfig, + assetType: string, + fromAddress: string, +): Promise { + const apiUrl = getBioforestApiUrl(chainConfig) + if (!apiUrl) return null + + const detail = await getAssetDetail(apiUrl, assetType, fromAddress) + return detail?.applyAddress ?? null +} + +/** + * Fetch minimum fee for destroy transaction + */ +export async function fetchBioforestBurnFee( + chainConfig: ChainConfig, + assetType: string, + amount: string, +): Promise { + const apiUrl = getBioforestApiUrl(chainConfig) + if (!apiUrl) { + // Fallback fee + return { + amount: Amount.fromRaw('1000', chainConfig.decimals, chainConfig.symbol), + symbol: chainConfig.symbol, + } + } + + try { + const feeRaw = await getDestroyTransactionMinFee(apiUrl, chainConfig.id, assetType, amount) + return { + amount: Amount.fromRaw(feeRaw, chainConfig.decimals, chainConfig.symbol), + symbol: chainConfig.symbol, + } + } catch (error) { + console.warn('[fetchBioforestBurnFee] Failed to get fee:', error) + return { + amount: Amount.fromRaw('1000', chainConfig.decimals, chainConfig.symbol), + symbol: chainConfig.symbol, + } + } +} + +export type SubmitBioforestBurnResult = + | { status: 'ok'; txHash: string } + | { status: 'password' } + | { status: 'password_required'; secondPublicKey: string } + | { status: 'error'; message: string } + +export interface SubmitBioforestBurnParams { + chainConfig: ChainConfig + walletId: string + password: string + fromAddress: string + recipientAddress: string + assetType: string + amount: Amount + fee?: Amount + twoStepSecret?: string +} + +/** + * Submit a destroy asset transaction + */ +export async function submitBioforestBurn({ + chainConfig, + walletId, + password, + fromAddress, + recipientAddress, + assetType, + amount, + fee, + twoStepSecret, +}: SubmitBioforestBurnParams): Promise { + // Get mnemonic from wallet storage + let secret: string + try { + secret = await walletStorageService.getMnemonic(walletId, password) + } catch (error) { + if (error instanceof WalletStorageError && error.code === WalletStorageErrorCode.DECRYPTION_FAILED) { + return { status: 'password' } + } + return { + status: 'error', + message: error instanceof Error ? error.message : '未知错误', + } + } + + if (!amount.isPositive()) { + return { status: 'error', message: '请输入有效金额' } + } + + const apiUrl = getBioforestApiUrl(chainConfig) + if (!apiUrl) { + return { status: 'error', message: 'API URL 未配置' } + } + + try { + console.log('[submitBioforestBurn] Starting destroy:', { + apiUrl, + fromAddress, + recipientAddress, + assetType, + amount: amount.toRawString() + }) + + // Check if pay password is required but not provided + const addressInfo = await getAddressInfo(apiUrl, fromAddress) + console.log('[submitBioforestBurn] Address info:', { hasSecondPubKey: !!addressInfo.secondPublicKey }) + + if (addressInfo.secondPublicKey && !twoStepSecret) { + console.log('[submitBioforestBurn] Pay password required') + return { + status: 'password_required', + secondPublicKey: addressInfo.secondPublicKey, + } + } + + // Verify pay password if provided + if (twoStepSecret && addressInfo.secondPublicKey) { + console.log('[submitBioforestBurn] Verifying pay password...') + const isValid = await verifyTwoStepSecret(chainConfig.id, secret, twoStepSecret, addressInfo.secondPublicKey) + console.log('[submitBioforestBurn] Pay password verification result:', isValid) + if (!isValid) { + return { status: 'error', message: '安全密码验证失败' } + } + } + + // Create destroy transaction using SDK + console.log('[submitBioforestBurn] Creating transaction...') + const transaction = await createDestroyTransaction({ + baseUrl: apiUrl, + chainId: chainConfig.id, + mainSecret: secret, + paySecret: twoStepSecret, + from: fromAddress, + recipientId: recipientAddress, + assetType, + amount: amount.toRawString(), + fee: fee?.toRawString(), + }) + const txHash = transaction.signature + console.log('[submitBioforestBurn] Transaction created:', txHash?.slice(0, 20)) + + // Broadcast transaction + console.log('[submitBioforestBurn] Broadcasting...') + await broadcastTransaction(apiUrl, transaction).catch((err) => { + console.warn('[submitBioforestBurn] Broadcast warning (may still succeed):', err.message) + }) + + console.log('[submitBioforestBurn] SUCCESS! txHash:', txHash) + return { status: 'ok', txHash } + } catch (error) { + console.error('[submitBioforestBurn] FAILED:', error) + + const errorMessage = error instanceof Error ? error.message : String(error) + + if (errorMessage.includes('insufficient') || errorMessage.includes('余额不足')) { + return { status: 'error', message: '余额不足' } + } + + if (errorMessage.includes('fee') || errorMessage.includes('手续费')) { + return { status: 'error', message: '手续费不足' } + } + + return { + status: 'error', + message: errorMessage || '销毁失败,请稍后重试', + } + } +} diff --git a/src/hooks/use-burn.test.ts b/src/hooks/use-burn.test.ts new file mode 100644 index 000000000..06d5df043 --- /dev/null +++ b/src/hooks/use-burn.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useBurn } from './use-burn' +import { Amount } from '@/types/amount' +import type { AssetInfo } from '@/types/asset' + +const mockAsset: AssetInfo = { + assetType: 'TEST', + name: 'Test Token', + amount: Amount.fromFormatted('100', 8, 'TEST'), + decimals: 8, +} + +describe('useBurn', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('initialization', () => { + it('initializes with default state', () => { + const { result } = renderHook(() => useBurn()) + + expect(result.current.state.step).toBe('input') + expect(result.current.state.asset).toBeNull() + expect(result.current.state.amount).toBeNull() + expect(result.current.state.recipientAddress).toBeNull() + expect(result.current.canProceed).toBe(false) + }) + + it('initializes with initial asset', () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset }) + ) + + expect(result.current.state.asset).toEqual(mockAsset) + }) + }) + + describe('setAmount', () => { + it('sets amount correctly', () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset }) + ) + + const newAmount = Amount.fromFormatted('50', 8, 'TEST') + act(() => { + result.current.setAmount(newAmount) + }) + + expect(result.current.state.amount?.toFormatted()).toBe('50') + }) + + it('clears amount error on change', () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset }) + ) + + // Set invalid amount first + act(() => { + result.current.setAmount(Amount.fromFormatted('0', 8, 'TEST')) + }) + + // Try to proceed (will set error) + act(() => { + result.current.goToConfirm() + }) + + // Set new amount should clear error + act(() => { + result.current.setAmount(Amount.fromFormatted('10', 8, 'TEST')) + }) + + expect(result.current.state.amountError).toBeNull() + }) + }) + + describe('setAsset', () => { + it('sets asset and fetches fee in mock mode', async () => { + const { result } = renderHook(() => useBurn({ useMock: true })) + + act(() => { + result.current.setAsset(mockAsset) + }) + + await waitFor(() => { + expect(result.current.state.asset).toEqual(mockAsset) + expect(result.current.state.feeLoading).toBe(false) + }) + }) + }) + + describe('goToConfirm', () => { + it('validates amount is positive', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + // Set zero amount + act(() => { + result.current.setAmount(Amount.fromFormatted('0', 8, 'TEST')) + }) + + let success: boolean + act(() => { + success = result.current.goToConfirm() + }) + + expect(success!).toBe(false) + expect(result.current.state.amountError).toBeTruthy() + }) + + it('validates amount is not greater than balance', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + // Set amount greater than balance (100) + act(() => { + result.current.setAmount(Amount.fromFormatted('150', 8, 'TEST')) + }) + + let success: boolean + act(() => { + success = result.current.goToConfirm() + }) + + expect(success!).toBe(false) + expect(result.current.state.amountError).toBeTruthy() + }) + + it('proceeds to confirm with valid data', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + // Set valid amount + act(() => { + result.current.setAmount(Amount.fromFormatted('50', 8, 'TEST')) + }) + + let success: boolean + act(() => { + success = result.current.goToConfirm() + }) + + expect(success!).toBe(true) + expect(result.current.state.step).toBe('confirm') + }) + }) + + describe('goBack', () => { + it('returns to input step', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + // Go to confirm + act(() => { + result.current.setAmount(Amount.fromFormatted('50', 8, 'TEST')) + result.current.goToConfirm() + }) + + expect(result.current.state.step).toBe('confirm') + + // Go back + act(() => { + result.current.goBack() + }) + + expect(result.current.state.step).toBe('input') + }) + }) + + describe('submit (mock mode)', () => { + it('submits successfully in mock mode', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + // Setup + act(() => { + result.current.setAmount(Amount.fromFormatted('50', 8, 'TEST')) + result.current.goToConfirm() + }) + + // Submit + let submitResult: Awaited> + await act(async () => { + submitResult = await result.current.submit('password') + }) + + expect(submitResult!.status).toBe('ok') + expect(result.current.state.step).toBe('result') + expect(result.current.state.resultStatus).toBe('success') + }) + }) + + describe('reset', () => { + it('resets to initial state', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + // Make changes + act(() => { + result.current.setAmount(Amount.fromFormatted('50', 8, 'TEST')) + result.current.goToConfirm() + }) + + // Reset + act(() => { + result.current.reset() + }) + + expect(result.current.state.step).toBe('input') + expect(result.current.state.amount).toBeNull() + }) + }) + + describe('canProceed', () => { + it('returns false without asset', () => { + const { result } = renderHook(() => useBurn()) + expect(result.current.canProceed).toBe(false) + }) + + it('returns false without amount', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + expect(result.current.canProceed).toBe(false) + }) + + it('returns true with valid asset and amount', async () => { + const { result } = renderHook(() => + useBurn({ initialAsset: mockAsset, useMock: true }) + ) + + await waitFor(() => { + expect(result.current.state.feeLoading).toBe(false) + }) + + act(() => { + result.current.setAmount(Amount.fromFormatted('50', 8, 'TEST')) + }) + + expect(result.current.canProceed).toBe(true) + }) + }) +}) diff --git a/src/hooks/use-burn.ts b/src/hooks/use-burn.ts new file mode 100644 index 000000000..1b9397d14 --- /dev/null +++ b/src/hooks/use-burn.ts @@ -0,0 +1,383 @@ +/** + * Hook for managing burn (destroy asset) flow state + * + * Only supports BioForest chains. Main asset cannot be destroyed. + */ + +import { useState, useCallback, useMemo, useEffect } from 'react' +import type { AssetInfo } from '@/types/asset' +import { Amount } from '@/types/amount' +import type { BurnState, UseBurnOptions, UseBurnReturn, BurnSubmitResult } from './use-burn.types' +import { fetchAssetApplyAddress, fetchBioforestBurnFee, submitBioforestBurn } from './use-burn.bioforest' + +const initialState: BurnState = { + step: 'input', + asset: null, + amount: null, + amountError: null, + recipientAddress: null, + feeAmount: null, + feeMinAmount: null, + feeSymbol: '', + feeLoading: false, + isSubmitting: false, + resultStatus: null, + txHash: null, + errorMessage: null, +} + +// Mock fee for non-bioforest or mock mode +const MOCK_FEE = { amount: '0.001', symbol: 'BFM' } + +/** + * Validate amount input + */ +function validateAmountInput(amount: Amount | null, asset: AssetInfo | null): string | null { + if (!amount || !asset) return null + + if (!amount.isPositive()) { + return '请输入有效金额' + } + + const balance = asset.amount + if (amount.gt(balance)) { + return '销毁数量不能大于余额' + } + + return null +} + +/** + * Hook for managing burn flow + */ +export function useBurn(options: UseBurnOptions = {}): UseBurnReturn { + const { + initialAsset, + assetLocked = false, + useMock = true, + walletId, + fromAddress, + chainConfig + } = options + + const [state, setState] = useState({ + ...initialState, + asset: initialAsset ?? null, + }) + + const isBioforestChain = chainConfig?.chainKind === 'bioforest' + + // Validate amount + const validateAmount = useCallback((amount: Amount | null, asset: AssetInfo | null): string | null => { + return validateAmountInput(amount, asset) + }, []) + + // Set amount + const setAmount = useCallback((amount: Amount | null) => { + setState((prev) => ({ + ...prev, + amount, + amountError: null, + })) + }, []) + + // Set asset and fetch applyAddress + fee + const setAsset = useCallback((asset: AssetInfo) => { + setState((prev) => ({ + ...prev, + asset, + amount: null, + amountError: null, + feeLoading: true, + recipientAddress: null, + })) + + const shouldUseMock = useMock || !isBioforestChain || !chainConfig || !fromAddress + + if (shouldUseMock) { + // Mock mode + setTimeout(() => { + setState((prev) => ({ + ...prev, + recipientAddress: 'mock_apply_address', + feeAmount: Amount.fromFormatted(MOCK_FEE.amount, asset.decimals, MOCK_FEE.symbol), + feeMinAmount: Amount.fromFormatted(MOCK_FEE.amount, asset.decimals, MOCK_FEE.symbol), + feeSymbol: chainConfig?.symbol ?? MOCK_FEE.symbol, + feeLoading: false, + })) + }, 300) + return + } + + // Real mode - fetch applyAddress and fee + void (async () => { + try { + // Fetch asset's applyAddress + const applyAddress = await fetchAssetApplyAddress(chainConfig, asset.assetType, fromAddress) + if (!applyAddress) { + setState((prev) => ({ + ...prev, + feeLoading: false, + errorMessage: '无法获取资产发行地址', + })) + return + } + + // Fetch fee (use placeholder amount for fee calculation) + const feeResult = await fetchBioforestBurnFee(chainConfig, asset.assetType, '1') + + setState((prev) => ({ + ...prev, + recipientAddress: applyAddress, + feeAmount: feeResult.amount, + feeMinAmount: feeResult.amount, + feeSymbol: feeResult.symbol, + feeLoading: false, + })) + } catch (error) { + setState((prev) => ({ + ...prev, + feeLoading: false, + errorMessage: error instanceof Error ? error.message : '获取手续费失败', + })) + } + })() + }, [chainConfig, fromAddress, isBioforestChain, useMock]) + + // Initialize with initial asset + useEffect(() => { + if (initialAsset && !state.recipientAddress) { + setAsset(initialAsset) + } + }, [initialAsset, setAsset, state.recipientAddress]) + + // Check if can proceed + const canProceed = useMemo(() => { + return !!( + state.asset && + state.amount && + state.amount.isPositive() && + state.recipientAddress && + !state.feeLoading + ) + }, [state.amount, state.asset, state.recipientAddress, state.feeLoading]) + + // Validate and go to confirm + const goToConfirm = useCallback((): boolean => { + const amountError = validateAmount(state.amount, state.asset) + + if (amountError) { + setState((prev) => ({ + ...prev, + amountError, + })) + return false + } + + if (!state.recipientAddress) { + setState((prev) => ({ + ...prev, + errorMessage: '资产发行地址未获取', + })) + return false + } + + setState((prev) => ({ + ...prev, + step: 'confirm', + amountError: null, + })) + return true + }, [state.amount, state.asset, state.recipientAddress, validateAmount]) + + // Go back to input + const goBack = useCallback(() => { + setState((prev) => ({ + ...prev, + step: 'input', + })) + }, []) + + // Submit transaction + const submit = useCallback(async (password: string): Promise => { + console.log('[useBurn.submit] Called with:', { useMock, chainKind: chainConfig?.chainKind, walletId, fromAddress }) + + if (useMock) { + console.log('[useBurn.submit] Using mock burn') + setState((prev) => ({ + ...prev, + step: 'burning', + isSubmitting: true, + })) + + // Simulate async operation + await new Promise((resolve) => setTimeout(resolve, 1500)) + + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'success', + txHash: `mock_burn_tx_${Date.now()}`, + })) + + return { status: 'ok', txHash: `mock_burn_tx_${Date.now()}` } + } + + if (!chainConfig || chainConfig.chainKind !== 'bioforest') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: '仅支持 BioForest 链的资产销毁', + })) + return { status: 'error', message: '仅支持 BioForest 链' } + } + + if (!walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: '参数不完整', + })) + return { status: 'error', message: '参数不完整' } + } + + setState((prev) => ({ + ...prev, + step: 'burning', + isSubmitting: true, + errorMessage: null, + })) + + const result = await submitBioforestBurn({ + chainConfig, + walletId, + password, + fromAddress, + recipientAddress: state.recipientAddress, + assetType: state.asset.assetType, + amount: state.amount, + fee: state.feeAmount ?? undefined, + }) + + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })) + return { status: 'password' } + } + + if (result.status === 'password_required') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })) + return { status: 'two_step_secret_required', secondPublicKey: result.secondPublicKey } + } + + if (result.status === 'error') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: result.message, + })) + return { status: 'error', message: result.message } + } + + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'success', + txHash: result.txHash, + })) + + return { status: 'ok', txHash: result.txHash } + }, [chainConfig, fromAddress, state.amount, state.asset, state.feeAmount, state.recipientAddress, useMock, walletId]) + + // Submit with two-step secret (pay password) + const submitWithTwoStepSecret = useCallback(async (password: string, twoStepSecret: string): Promise => { + if (!chainConfig || !walletId || !fromAddress || !state.asset || !state.amount || !state.recipientAddress) { + return { status: 'error', message: '参数不完整' } + } + + setState((prev) => ({ + ...prev, + step: 'burning', + isSubmitting: true, + errorMessage: null, + })) + + const result = await submitBioforestBurn({ + chainConfig, + walletId, + password, + fromAddress, + recipientAddress: state.recipientAddress, + assetType: state.asset.assetType, + amount: state.amount, + fee: state.feeAmount ?? undefined, + twoStepSecret, + }) + + if (result.status === 'password') { + setState((prev) => ({ + ...prev, + step: 'confirm', + isSubmitting: false, + })) + return { status: 'password' } + } + + if (result.status === 'error') { + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'failed', + errorMessage: result.message, + })) + return { status: 'error', message: result.message } + } + + setState((prev) => ({ + ...prev, + step: 'result', + isSubmitting: false, + resultStatus: 'success', + txHash: result.txHash, + })) + + return { status: 'ok', txHash: result.txHash } + }, [chainConfig, fromAddress, state.amount, state.asset, state.feeAmount, state.recipientAddress, walletId]) + + // Reset to initial state + const reset = useCallback(() => { + setState({ + ...initialState, + asset: initialAsset ?? null, + }) + }, [initialAsset]) + + return { + state, + setAmount, + setAsset, + goToConfirm, + goBack, + submit, + submitWithTwoStepSecret, + reset, + canProceed, + assetLocked, + } +} diff --git a/src/hooks/use-burn.types.ts b/src/hooks/use-burn.types.ts new file mode 100644 index 000000000..2e06a695b --- /dev/null +++ b/src/hooks/use-burn.types.ts @@ -0,0 +1,84 @@ +import type { AssetInfo } from '@/types/asset' +import type { Amount } from '@/types/amount' +import type { ChainConfig } from '@/services/chain-config' + +/** Burn flow step */ +export type BurnStep = 'input' | 'confirm' | 'burning' | 'result' + +/** Burn result status */ +export type BurnResultStatus = 'success' | 'failed' | 'pending' + +/** Burn flow state */ +export interface BurnState { + /** Current step */ + step: BurnStep + /** Selected asset to burn (must not be main asset) */ + asset: AssetInfo | null + /** Amount to burn (as Amount object, null if empty/invalid) */ + amount: Amount | null + /** Amount validation error */ + amountError: string | null + /** Recipient address (asset's applyAddress) */ + recipientAddress: string | null + /** Estimated fee amount */ + feeAmount: Amount | null + /** Minimum fee from estimation */ + feeMinAmount: Amount | null + /** Fee token symbol (always main asset) */ + feeSymbol: string + /** Fee loading state */ + feeLoading: boolean + /** Is submitting transaction */ + isSubmitting: boolean + /** Result status */ + resultStatus: BurnResultStatus | null + /** Transaction hash */ + txHash: string | null + /** Error message */ + errorMessage: string | null +} + +export interface UseBurnOptions { + /** Initial asset */ + initialAsset?: AssetInfo | undefined + /** Whether asset can be changed (default: true) */ + assetLocked?: boolean | undefined + /** Mock mode (default: true) */ + useMock?: boolean | undefined + /** Wallet id for decrypting secret */ + walletId?: string | undefined + /** Sender address */ + fromAddress?: string | undefined + /** Chain config (bioforest only) */ + chainConfig?: ChainConfig | null | undefined +} + +/** Submit result type */ +export type BurnSubmitResult = + | { status: 'ok'; txHash?: string } + | { status: 'password' } + | { status: 'two_step_secret_required'; secondPublicKey: string } + | { status: 'error'; message?: string } + +export interface UseBurnReturn { + /** Current state */ + state: BurnState + /** Set amount (Amount object or null for empty/invalid) */ + setAmount: (amount: Amount | null) => void + /** Set asset to burn */ + setAsset: (asset: AssetInfo) => void + /** Validate and go to confirm */ + goToConfirm: () => boolean + /** Go back to input */ + goBack: () => void + /** Submit transaction */ + submit: (password: string) => Promise + /** Submit transaction with pay password */ + submitWithTwoStepSecret: (password: string, twoStepSecret: string) => Promise + /** Reset to initial state */ + reset: () => void + /** Check if can proceed to confirm */ + canProceed: boolean + /** Whether asset selection is locked */ + assetLocked: boolean +} diff --git a/src/i18n/locales/en/home.json b/src/i18n/locales/en/home.json index fd02df32b..760d06a12 100644 --- a/src/i18n/locales/en/home.json +++ b/src/i18n/locales/en/home.json @@ -12,6 +12,9 @@ "noAssets": "No Assets", "noAssetsOnChain": "No tokens on {{chain}}", "selectNetwork": "Select Network", - "addressCopied": "Address copied" + "addressCopied": "Address copied", + "tokenActions": "Token Actions", + "transfer": "Transfer", + "destroy": "Destroy" } } diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json index a01ebe8c1..a3197bd9f 100644 --- a/src/i18n/locales/en/transaction.json +++ b/src/i18n/locales/en/transaction.json @@ -49,6 +49,7 @@ "toAddressLabel": "Recipient Address", "toAddressPlaceholder": "Enter {{chain}} address", "amountLabel": "Amount", + "assetLabel": "Transfer Asset", "networkWarning": "Please ensure the recipient address is a {{chain}} network address. Sending to the wrong network cannot be recovered", "continue": "Continue", "cameraPermissionRequired": "Camera permission required for scanning", @@ -58,7 +59,18 @@ "twoStepSecretTitle": "Security Password Required", "twoStepSecretDescription": "This address has a security password set. Please enter your security password to confirm the transfer.", "twoStepSecretPlaceholder": "Enter security password", - "twoStepSecretError": "Incorrect security password" + "twoStepSecretError": "Incorrect security password", + "fee": "Fee" + }, + "destroyPage": { + "title": "Destroy", + "resultTitle": "Destroy Result", + "assetLabel": "Asset to Destroy", + "amountLabel": "Destroy Amount", + "warning": "This action cannot be undone. Please verify the amount before confirming.", + "confirm": "Confirm Destroy", + "notSupported": "Asset destruction not supported on this chain", + "noDestroyableAssets": "No assets available for destruction" }, "aboutFee": "About Fee", "amount": "Amount", diff --git a/src/i18n/locales/zh-CN/home.json b/src/i18n/locales/zh-CN/home.json index 37c47ec61..014a9504d 100644 --- a/src/i18n/locales/zh-CN/home.json +++ b/src/i18n/locales/zh-CN/home.json @@ -12,6 +12,9 @@ "noAssets": "暂无资产", "noAssetsOnChain": "{{chain}} 链上暂无代币", "selectNetwork": "选择网络", - "addressCopied": "地址已复制" + "addressCopied": "地址已复制", + "tokenActions": "资产操作", + "transfer": "转账", + "destroy": "销毁" } } diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json index 5b9a05ad0..9c6c974f7 100644 --- a/src/i18n/locales/zh-CN/transaction.json +++ b/src/i18n/locales/zh-CN/transaction.json @@ -49,6 +49,7 @@ "toAddressLabel": "收款地址", "toAddressPlaceholder": "输入 {{chain}} 地址", "amountLabel": "金额", + "assetLabel": "转账资产", "networkWarning": "请确保收款地址为 {{chain}} 网络地址,发送到错误网络将无法找回", "continue": "继续", "cameraPermissionRequired": "需要相机权限才能扫描", @@ -58,7 +59,18 @@ "twoStepSecretTitle": "需要安全密码", "twoStepSecretDescription": "该地址已设置安全密码,请输入安全密码确认转账。", "twoStepSecretPlaceholder": "输入安全密码", - "twoStepSecretError": "安全密码错误" + "twoStepSecretError": "安全密码错误", + "fee": "手续费" + }, + "destroyPage": { + "title": "销毁", + "resultTitle": "销毁结果", + "assetLabel": "销毁资产", + "amountLabel": "销毁数量", + "warning": "销毁操作不可撤销,请仔细核对销毁数量。", + "confirm": "确认销毁", + "notSupported": "当前链不支持资产销毁", + "noDestroyableAssets": "暂无可销毁的资产" }, "history": { "title": "交易记录", diff --git a/src/pages/destroy/destroy.stories.tsx b/src/pages/destroy/destroy.stories.tsx new file mode 100644 index 000000000..ad7440afa --- /dev/null +++ b/src/pages/destroy/destroy.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { DestroyPage } from './index' +import { StorybookProviders } from '@/test/storybook-providers' +import { StackflowStoryWrapper } from '@/test/stackflow-story-wrapper' + +const meta: Meta = { + title: 'Pages/DestroyPage', + component: DestroyPage, + decorators: [ + (Story) => ( + + +
+ +
+
+
+ ), + ], + parameters: { + layout: 'fullscreen', + viewport: { + defaultViewport: 'mobile1', + }, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default (No Wallet)', +} + +export const WithMockData: Story = { + name: 'With Mock Wallet', + parameters: { + mockData: { + hasWallet: true, + selectedChain: 'bfmeta', + tokens: [ + { symbol: 'BFM', name: 'BFMeta', balance: '1000', decimals: 8 }, + { symbol: 'TEST', name: 'Test Token', balance: '500', decimals: 8 }, + { symbol: 'USDT', name: 'Tether USD', balance: '1000', decimals: 8 }, + ], + }, + }, +} diff --git a/src/pages/destroy/index.tsx b/src/pages/destroy/index.tsx new file mode 100644 index 000000000..7107852e5 --- /dev/null +++ b/src/pages/destroy/index.tsx @@ -0,0 +1,365 @@ +/** + * DestroyPage - 资产销毁页面 + * + * 仅支持 BioForest 链,主资产不可销毁 + */ + +import { useEffect, useMemo, useRef, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigation, useActivityParams, useFlow } from '@/stackflow' +import { setTransferConfirmCallback, setTransferWalletLockCallback } from '@/stackflow/activities/sheets' +import { PageHeader } from '@/components/layout/page-header' +import { AssetSelector } from '@/components/asset' +import { AmountInput } from '@/components/transfer/amount-input' +import { GradientButton } from '@/components/common/gradient-button' +import { Alert } from '@/components/common/alert' +import { ChainIcon } from '@/components/wallet/chain-icon' +import { SendResult } from '@/components/transfer/send-result' +import { useToast, useHaptics } from '@/services' +import { useBurn } from '@/hooks/use-burn' +import { Amount } from '@/types/amount' +import type { AssetInfo } from '@/types/asset' +import type { TokenInfo } from '@/components/token/token-item' +import { IconFlame } from '@tabler/icons-react' +import { + useChainConfigState, + chainConfigSelectors, + useCurrentChainAddress, + useCurrentWallet, + useSelectedChain, + useCurrentChainTokens, + type ChainType, +} from '@/stores' + +const CHAIN_NAMES: Record = { + ethereum: 'Ethereum', + bitcoin: 'Bitcoin', + tron: 'Tron', + binance: 'BSC', + bfmeta: 'BFMeta', + ccchain: 'CCChain', + pmchain: 'PMChain', + bfchainv2: 'BFChain V2', + btgmeta: 'BTGMeta', + biwmeta: 'BIWMeta', + ethmeta: 'ETHMeta', + malibu: 'Malibu', +} + +/** Convert TokenInfo to AssetInfo */ +function tokenToAsset(token: TokenInfo): AssetInfo { + return { + assetType: token.symbol, + name: token.name, + amount: Amount.fromFormatted(token.balance, token.decimals ?? 8, token.symbol), + decimals: token.decimals ?? 8, + logoUrl: token.icon, + } +} + +/** Convert AssetInfo to TokenInfo */ +function assetToToken(asset: AssetInfo, chain: ChainType): TokenInfo { + return { + symbol: asset.assetType, + name: asset.name ?? asset.assetType, + balance: asset.amount.toFormatted(), + decimals: asset.decimals, + chain, + icon: asset.logoUrl, + } +} + +export function DestroyPage() { + const { t } = useTranslation(['transaction', 'common', 'security']) + const { goBack: navGoBack } = useNavigation() + const { push } = useFlow() + const toast = useToast() + const haptics = useHaptics() + const isWalletLockSheetOpen = useRef(false) + + // Read params + const { assetType: initialAssetType, assetLocked: assetLockedParam } = useActivityParams<{ + assetType?: string + assetLocked?: string + }>() + + const selectedChain = useSelectedChain() + const currentWallet = useCurrentWallet() + const currentChainAddress = useCurrentChainAddress() + const chainConfigState = useChainConfigState() + const chainConfig = chainConfigState.snapshot + ? chainConfigSelectors.getChainById(chainConfigState, selectedChain) + : null + const selectedChainName = chainConfig?.name ?? CHAIN_NAMES[selectedChain] ?? selectedChain + const tokens = useCurrentChainTokens() + + // Filter out main asset (cannot be destroyed) + const destroyableTokens = useMemo(() => { + if (!chainConfig) return [] + return tokens.filter((token) => token.symbol.toUpperCase() !== chainConfig.symbol.toUpperCase()) + }, [tokens, chainConfig]) + + // Find initial asset from params + const initialAsset = useMemo(() => { + if (!initialAssetType || destroyableTokens.length === 0) return null + const found = destroyableTokens.find( + (t) => t.symbol.toUpperCase() === initialAssetType.toUpperCase() + ) + return found ? tokenToAsset(found) : null + }, [initialAssetType, destroyableTokens]) + + const assetLocked = assetLockedParam === 'true' + + const { + state, + setAmount, + setAsset, + goToConfirm, + submit, + submitWithTwoStepSecret, + reset, + canProceed + } = useBurn({ + initialAsset: initialAsset ?? undefined, + assetLocked, + useMock: false, + walletId: currentWallet?.id, + fromAddress: currentChainAddress?.address, + chainConfig, + }) + + // Handle asset selection + const handleAssetSelect = useCallback((token: TokenInfo) => { + setAsset(tokenToAsset(token)) + }, [setAsset]) + + // Selected token for AssetSelector + const selectedToken = useMemo(() => { + if (!state.asset) return null + return assetToToken(state.asset, selectedChain) + }, [state.asset, selectedChain]) + + // Derive formatted values for display + const balance = state.asset?.amount ?? null + const symbol = state.asset?.assetType ?? 'TOKEN' + + const handleProceed = () => { + if (!goToConfirm()) return + + haptics.impact('light') + + // Set up callback: TransferConfirm -> TransferWalletLock + setTransferConfirmCallback( + async () => { + if (isWalletLockSheetOpen.current) return + isWalletLockSheetOpen.current = true + + await haptics.impact('medium') + + setTransferWalletLockCallback(async (walletLockKey: string, twoStepSecret?: string) => { + if (!twoStepSecret) { + const result = await submit(walletLockKey) + + if (result.status === 'password') { + return { status: 'wallet_lock_invalid' as const } + } + + if (result.status === 'two_step_secret_required') { + return { status: 'two_step_secret_required' as const } + } + + if (result.status === 'ok') { + isWalletLockSheetOpen.current = false + return { status: 'ok' as const, txHash: result.txHash } + } + + return { status: 'error' as const, message: '销毁失败' } + } + + const result = await submitWithTwoStepSecret(walletLockKey, twoStepSecret) + + if (result.status === 'ok') { + isWalletLockSheetOpen.current = false + return { status: 'ok' as const, txHash: result.txHash } + } + + if (result.status === 'password') { + return { status: 'two_step_secret_invalid' as const, message: '安全密码错误' } + } + + return { status: 'error' as const, message: result.status === 'error' ? '销毁失败' : '未知错误' } + }) + + push('TransferWalletLockJob', { + title: t('security:walletLock.verifyTitle'), + }) + }, + { + minFee: state.feeMinAmount?.toFormatted() ?? state.feeAmount?.toFormatted() ?? '0', + } + ) + + push('TransferConfirmJob', { + amount: state.amount?.toFormatted() ?? '0', + symbol, + toAddress: state.recipientAddress ?? '', + feeAmount: state.feeAmount?.toFormatted() ?? '0', + feeSymbol: state.feeSymbol, + feeLoading: state.feeLoading ? 'true' : 'false', + }) + } + + const handleDone = () => { + if (state.resultStatus === 'success') { + haptics.impact('success') + } + navGoBack() + } + + const handleRetry = () => { + reset() + } + + const handleViewExplorer = () => { + if (state.txHash) { + toast.show(t('sendPage.explorerNotImplemented')) + } + } + + // Check if chain supports destroy + const isBioforestChain = chainConfig?.chainKind === 'bioforest' + + if (!currentWallet || !currentChainAddress) { + return ( +
+ +
+

{t('history.noWallet')}

+
+
+ ) + } + + if (!isBioforestChain) { + return ( +
+ +
+ +

+ {t('destroyPage.notSupported', '当前链不支持资产销毁')} +

+
+
+ ) + } + + // Result step + if (state.step === 'result' || state.step === 'burning') { + return ( +
+ + +
+ ) + } + + return ( +
+ + +
+ {/* Current chain info & sender address */} +
+
+ + {selectedChainName} +
+ {currentChainAddress?.address && ( +
+ {t('sendPage.from')}: + + {currentChainAddress.address.slice(0, 8)}...{currentChainAddress.address.slice(-6)} + +
+ )} +
+ + {/* Asset selector */} +
+ + + {destroyableTokens.length === 0 && ( +

+ {t('destroyPage.noDestroyableAssets', '暂无可销毁的资产')} +

+ )} +
+ + {/* Amount input */} + {state.asset && ( + + )} + + {/* Warning */} + + {t('destroyPage.warning', '销毁操作不可撤销,请仔细核对销毁数量。')} + + + {/* Fee info */} + {state.feeAmount && ( +
+
+ {t('sendPage.fee', '手续费')} + + {state.feeAmount.toFormatted()} {state.feeSymbol} + +
+
+ )} + + {/* Continue button */} +
+ + + {t('destroyPage.confirm', '确认销毁')} + +
+
+
+ ) +} + +export default DestroyPage diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index ec979aa76..5ca07b75a 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -5,8 +5,10 @@ import { setTransferConfirmCallback, setTransferWalletLockCallback, setScannerRe import type { Contact, ContactAddress } from '@/stores'; import { addressBookStore, addressBookSelectors, preferencesActions } from '@/stores'; import { PageHeader } from '@/components/layout/page-header'; +import { AssetSelector } from '@/components/asset'; import { AddressInput } from '@/components/transfer'; import { AmountInput } from '@/components/transfer/amount-input'; +import type { TokenInfo } from '@/components/token/token-item'; import { GradientButton } from '@/components/common/gradient-button'; import { Alert } from '@/components/common/alert'; import { ChainIcon } from '@/components/wallet/chain-icon'; @@ -21,6 +23,7 @@ import { useCurrentChainAddress, useCurrentWallet, useSelectedChain, + useCurrentChainTokens, type ChainType, } from '@/stores'; @@ -48,10 +51,14 @@ export function SendPage() { const isWalletLockSheetOpen = useRef(false); // Read params for pre-fill from scanner - const { address: initialAddress, amount: initialAmount } = useActivityParams<{ + const { address: initialAddress, amount: initialAmount, assetType: initialAssetType, assetLocked: assetLockedParam } = useActivityParams<{ address?: string; amount?: string; + assetType?: string; + assetLocked?: string; }>(); + + const assetLocked = assetLockedParam === 'true'; const selectedChain = useSelectedChain(); const currentWallet = useCurrentWallet(); @@ -61,10 +68,28 @@ export function SendPage() { ? chainConfigSelectors.getChainById(chainConfigState, selectedChain) : null; const selectedChainName = chainConfig?.name ?? CHAIN_NAMES[selectedChain] ?? selectedChain; + const tokens = useCurrentChainTokens(); - const defaultAsset = useMemo(() => { + // Find initial asset from params or use default + const initialAsset = useMemo(() => { if (!chainConfig) return null; - const nativeBalance = currentChainAddress?.tokens.find((token) => token.symbol === chainConfig.symbol); + + // If assetType is specified, find it in tokens + if (initialAssetType) { + const found = tokens.find((t) => t.symbol.toUpperCase() === initialAssetType.toUpperCase()); + if (found) { + return { + assetType: found.symbol, + name: found.name, + amount: Amount.fromFormatted(found.balance, found.decimals ?? chainConfig.decimals, found.symbol), + decimals: found.decimals ?? chainConfig.decimals, + logoUrl: found.icon, + }; + } + } + + // Default to native asset + const nativeBalance = tokens.find((token) => token.symbol === chainConfig.symbol); const balanceFormatted = nativeBalance?.balance ?? '0'; return { assetType: chainConfig.symbol, @@ -72,10 +97,35 @@ export function SendPage() { amount: Amount.fromFormatted(balanceFormatted, chainConfig.decimals, chainConfig.symbol), decimals: chainConfig.decimals, }; - }, [chainConfig, currentChainAddress?.tokens]); + }, [chainConfig, tokens, initialAssetType]); + + // Selected token for AssetSelector (convert from state.asset) + const selectedToken = useMemo((): TokenInfo | null => { + if (!state.asset) return null; + return { + symbol: state.asset.assetType, + name: state.asset.name ?? state.asset.assetType, + balance: state.asset.amount.toFormatted(), + decimals: state.asset.decimals, + chain: selectedChain, + icon: state.asset.logoUrl, + }; + }, [state.asset, selectedChain]); + + // Handle asset selection from AssetSelector + const handleAssetSelect = useCallback((token: TokenInfo) => { + const asset = { + assetType: token.symbol, + name: token.name, + amount: Amount.fromFormatted(token.balance, token.decimals ?? chainConfig?.decimals ?? 8, token.symbol), + decimals: token.decimals ?? chainConfig?.decimals ?? 8, + logoUrl: token.icon, + }; + setAsset(asset); + }, [chainConfig?.decimals, setAsset]); const { state, setToAddress, setAmount, setAsset, setFee, goToConfirm, submit, submitWithTwoStepSecret, reset, canProceed } = useSend({ - initialAsset: defaultAsset ?? undefined, + initialAsset: initialAsset ?? undefined, useMock: false, walletId: currentWallet?.id, fromAddress: currentChainAddress?.address, @@ -83,9 +133,9 @@ export function SendPage() { }); useEffect(() => { - if (!defaultAsset) return; - setAsset(defaultAsset); - }, [defaultAsset, setAsset]); + if (!initialAsset) return; + setAsset(initialAsset); + }, [initialAsset, setAsset]); // Pre-fill from search params (scanner integration) useEffect(() => { @@ -285,6 +335,22 @@ export function SendPage() { )} + {/* Asset selector (only show if multiple tokens available) */} + {tokens.length > 1 && ( +
+ + +
+ )} + {/* Address input - 直接从 addressBookStore 读取(单一数据源) */} | undefined +} + +/** + * Get minimum fee for destroying an asset + */ +export async function getDestroyTransactionMinFee( + baseUrl: string, + chainId: string, + assetType: string, + amount: string, +): Promise { + const core = await getBioforestCore(chainId) + + const lastBlock = await getLastBlock(baseUrl) + const applyBlockHeight = lastBlock.height + const timestamp = lastBlock.timestamp + + return core.transactionController.getDestoryAssetTransactionMinFee({ + transaction: { + applyBlockHeight, + timestamp, + remark: {}, + }, + assetInfo: { + sourceChainName: await core.getChainName(), + sourceChainMagic: await core.getMagic(), + assetType, + amount, + }, + }) +} + +/** + * Create a destroy asset transaction using the SDK + * + * NOTE: The SDK uses "Destory" (typo from legacy code, not "Destroy") + */ +export async function createDestroyTransaction( + params: CreateDestroyParams, +): Promise { + const core = await getBioforestCore(params.chainId) + + const lastBlock = await getLastBlock(params.baseUrl) + const applyBlockHeight = lastBlock.height + const timestamp = lastBlock.timestamp + + // Calculate fee if not provided + let fee = params.fee + if (!fee) { + fee = await getDestroyTransactionMinFee( + params.baseUrl, + params.chainId, + params.assetType, + params.amount, + ) + } + + // Determine pay password version if set + let usePaySecretV1 = false + if (params.paySecret) { + const addressInfo = await getAddressInfo(params.baseUrl, params.from) + if (addressInfo.secondPublicKey) { + const version = await verifyTwoStepSecret( + params.chainId, + params.mainSecret, + params.paySecret, + addressInfo.secondPublicKey, + ) + usePaySecretV1 = version === 'v1' + } + } + + const secrets: Secrets = { + mainSecret: params.mainSecret, + ...(params.paySecret ? { paySecret: params.paySecret, usePaySecretV1 } : {}), + } + + const assetInfo: DestroyAssetInfo = { + sourceChainName: await core.getChainName(), + sourceChainMagic: await core.getMagic(), + assetType: params.assetType, + amount: params.amount, + } + + // SDK method is "createDestoryAssetTransactionJSON" (typo preserved) + return core.transactionController.createDestoryAssetTransactionJSON({ + secrets, + transaction: { + fee, + recipientId: params.recipientId, + applyBlockHeight, + timestamp, + remark: params.remark ?? {}, + effectiveBlockHeight: applyBlockHeight + 100, + }, + assetInfo, + }) +} + +/** + * Get asset detail including applyAddress (issuer address) + * @param baseUrl - Full wallet API URL + * @param assetType - Asset type to query + * @param address - Address to query (required by API) + */ +export async function getAssetDetail( + baseUrl: string, + assetType: string, + address: string, +): Promise<{ applyAddress: string; assetType: string } | null> { + const api = getApi(baseUrl) + try { + const result = await api.queryTokenDetail({ assetType, address }) + if (result?.applyAddress) { + return { + applyAddress: result.applyAddress, + assetType: result.assetType, + } + } + return null + } catch { + return null + } +} diff --git a/src/services/ecosystem/handlers/context.ts b/src/services/ecosystem/handlers/context.ts index 0e602d547..2f6c6487e 100644 --- a/src/services/ecosystem/handlers/context.ts +++ b/src/services/ecosystem/handlers/context.ts @@ -3,7 +3,7 @@ * 管理小程序回调的注册中心,支持多实例 */ -import type { BioAccount, BioSignedTransaction, BioUnsignedTransaction, TransferParams } from '../types' +import type { BioAccount, BioSignedTransaction, BioUnsignedTransaction, TransferParams, DestroyParams } from '../types' /** EVM 交易请求类型 */ export interface EvmTransactionRequest { @@ -80,6 +80,7 @@ export interface HandlerCallbacks { getConnectedAccounts: () => BioAccount[] showSigningDialog: (params: SigningParams) => Promise showTransferDialog: (params: TransferParams & { app: MiniappInfo }) => Promise<{ txHash: string } | null> + showDestroyDialog?: (params: DestroyParams & { app: MiniappInfo }) => Promise<{ txHash: string } | null> showSignTransactionDialog: (params: SignTransactionParams) => Promise // EVM (Ethereum/BSC) callbacks diff --git a/src/services/ecosystem/handlers/destroy.ts b/src/services/ecosystem/handlers/destroy.ts new file mode 100644 index 000000000..64f18cdb8 --- /dev/null +++ b/src/services/ecosystem/handlers/destroy.ts @@ -0,0 +1,53 @@ +/** + * Destroy asset method handlers + */ + +import type { MethodHandler, DestroyParams } from '../types' +import { BioErrorCodes } from '../types' +import { HandlerContext, type MiniappInfo } from './context' + +// 兼容旧 API +let _showDestroyDialog: ((params: DestroyParams & { app: MiniappInfo }) => Promise<{ txHash: string } | null>) | null = null + +/** @deprecated 使用 HandlerContext.register 替代 */ +export function setDestroyDialog(dialog: typeof _showDestroyDialog): void { + _showDestroyDialog = dialog +} + +/** 获取销毁对话框 */ +function getDestroyDialog(appId: string) { + const callbacks = HandlerContext.get(appId) + return callbacks?.showDestroyDialog ?? _showDestroyDialog +} + +/** bio_destroyAsset - Destroy an asset (BioForest chains only) */ +export const handleDestroyAsset: MethodHandler = async (params, context) => { + const opts = params as Partial | undefined + if (!opts?.from || !opts?.amount || !opts?.chain || !opts?.asset) { + throw Object.assign( + new Error('Missing required parameters: from, amount, chain, asset'), + { code: BioErrorCodes.INVALID_PARAMS } + ) + } + + const showDestroyDialog = getDestroyDialog(context.appId) + if (!showDestroyDialog) { + throw Object.assign(new Error('Destroy dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR }) + } + + const destroyParams: DestroyParams & { app: MiniappInfo } = { + from: opts.from, + amount: opts.amount, + chain: opts.chain, + asset: opts.asset, + app: { name: context.appName }, + } + + const result = await showDestroyDialog(destroyParams) + + if (!result) { + throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) + } + + return result +} diff --git a/src/services/ecosystem/handlers/index.ts b/src/services/ecosystem/handlers/index.ts index f5312a847..cba792d76 100644 --- a/src/services/ecosystem/handlers/index.ts +++ b/src/services/ecosystem/handlers/index.ts @@ -40,6 +40,11 @@ export { setTransferDialog, } from './transfer' +export { + handleDestroyAsset, + setDestroyDialog, +} from './destroy' + export { handleCreateTransaction, handleSignTransaction, diff --git a/src/services/ecosystem/provider.ts b/src/services/ecosystem/provider.ts index b1878e4b3..e9b5e1d9a 100644 --- a/src/services/ecosystem/provider.ts +++ b/src/services/ecosystem/provider.ts @@ -19,6 +19,7 @@ import { handleCreateTransaction, handleSignTransaction, handleSendTransaction, + handleDestroyAsset, registerEvmHandlers, registerTronHandlers, } from './handlers' @@ -46,6 +47,9 @@ export function initBioProvider(): void { // Transfer methods bridge.registerHandler('bio_sendTransaction', handleSendTransaction) + // Destroy asset (BioForest chains only) + bridge.registerHandler('bio_destroyAsset', handleDestroyAsset) + // EVM methods (Ethereum/BSC via window.ethereum) registerEvmHandlers((method, handler) => bridge.registerHandler(method, handler)) @@ -67,6 +71,7 @@ export function initBioProvider(): void { 'bio_createTransaction', 'bio_signTransaction', 'bio_sendTransaction', + 'bio_destroyAsset', ], evm: [ 'eth_chainId', @@ -97,6 +102,7 @@ export { setGetAccounts, setSigningDialog, setTransferDialog, + setDestroyDialog, setSignTransactionDialog, // EVM setters setEvmWalletPicker, diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index 021414c3a..11730bb18 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -18,6 +18,14 @@ export interface TransferParams { asset?: string } +/** Destroy asset parameters */ +export interface DestroyParams { + from: string + amount: string + chain: string + asset: string +} + /** Unsigned transaction payload (chain-specific) */ export interface BioUnsignedTransaction { chainId: string @@ -126,6 +134,12 @@ export const KNOWN_PERMISSIONS: Record = { description: '请求发送转账(需要您确认)', risk: 'high', }, + bio_destroyAsset: { + id: 'bio_destroyAsset', + name: '销毁资产', + description: '请求销毁资产(需要您确认,不可撤销)', + risk: 'high', + }, } /** Miniapp manifest - 完整的小程序元数据 */ diff --git a/src/stackflow/activities/DestroyActivity.tsx b/src/stackflow/activities/DestroyActivity.tsx new file mode 100644 index 000000000..dc5327059 --- /dev/null +++ b/src/stackflow/activities/DestroyActivity.tsx @@ -0,0 +1,22 @@ +import type { ActivityComponentType } from "@stackflow/react"; +import { AppScreen } from "@stackflow/plugin-basic-ui"; +import { ActivityParamsProvider } from "../hooks"; +import { ErrorBoundary } from "@/components/common/error-boundary"; +import { DestroyPage } from "@/pages/destroy"; + +type DestroyParams = { + assetType?: string; + assetLocked?: string; +}; + +export const DestroyActivity: ActivityComponentType = ({ params }) => { + return ( + + + + + + + + ); +}; diff --git a/src/stackflow/activities/SendActivity.tsx b/src/stackflow/activities/SendActivity.tsx index 32f8b8434..22be20c77 100644 --- a/src/stackflow/activities/SendActivity.tsx +++ b/src/stackflow/activities/SendActivity.tsx @@ -8,6 +8,8 @@ type SendParams = { address?: string; chain?: string; amount?: string; + assetType?: string; + assetLocked?: string; }; export const SendActivity: ActivityComponentType = ({ params }) => { diff --git a/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx new file mode 100644 index 000000000..40c467163 --- /dev/null +++ b/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx @@ -0,0 +1,240 @@ +/** + * MiniappDestroyConfirmJob - 小程序销毁资产确认对话框 + * 用于小程序请求销毁资产时显示 + */ + +import { useState, useCallback } from 'react' +import type { ActivityComponentType } from '@stackflow/react' +import { BottomSheet } from '@/components/layout/bottom-sheet' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { IconFlame, IconAlertTriangle, IconLoader2, IconApps } from '@tabler/icons-react' +import { useFlow } from '../../stackflow' +import { ActivityParamsProvider, useActivityParams } from '../../hooks' +import { setWalletLockConfirmCallback } from './WalletLockConfirmJob' +import { useCurrentWallet, useChainConfigState, chainConfigSelectors } from '@/stores' +import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage' +import { submitBioforestBurn } from '@/hooks/use-burn.bioforest' +import { Amount } from '@/types/amount' +import { AddressDisplay } from '@/components/wallet/address-display' +import { AmountDisplay } from '@/components/common/amount-display' + +type MiniappDestroyConfirmJobParams = { + /** 来源小程序名称 */ + appName: string + /** 来源小程序图标 */ + appIcon?: string + /** 发送地址 */ + from: string + /** 金额 */ + amount: string + /** 链 ID */ + chain: string + /** 资产类型 */ + asset: string +} + +function MiniappDestroyConfirmJobContent() { + const { t } = useTranslation(['common', 'transaction']) + const { pop, push } = useFlow() + const params = useActivityParams() + const { appName, appIcon, from, amount, chain, asset } = params + const currentWallet = useCurrentWallet() + const chainConfigState = useChainConfigState() + + const chainConfig = chainConfigState.snapshot + ? chainConfigSelectors.getChainById(chainConfigState, chain as 'bfmeta') + : null + + const [isConfirming, setIsConfirming] = useState(false) + + const handleConfirm = useCallback(() => { + if (isConfirming) return + + // 设置钱包锁验证回调 + setWalletLockConfirmCallback(async (password: string) => { + setIsConfirming(true) + + try { + if (!currentWallet?.id || !chainConfig) { + console.error('[MiniappDestroyConfirmJob] Missing wallet or chain config') + return false + } + + // 获取 applyAddress + const { fetchAssetApplyAddress } = await import('@/hooks/use-burn.bioforest') + const applyAddress = await fetchAssetApplyAddress(chainConfig, asset, from) + + if (!applyAddress) { + console.error('[MiniappDestroyConfirmJob] Failed to get asset apply address') + return false + } + + // 执行销毁 + const amountObj = Amount.fromFormatted(amount, chainConfig.decimals, asset) + + const result = await submitBioforestBurn({ + chainConfig, + walletId: currentWallet.id, + password, + fromAddress: from, + recipientAddress: applyAddress, + assetType: asset, + amount: amountObj, + }) + + if (result.status === 'password') { + return false + } + + if (result.status === 'error') { + console.error('[MiniappDestroyConfirmJob] Destroy failed:', result.message) + return false + } + + // 发送成功事件 + const event = new CustomEvent('miniapp-destroy-confirm', { + detail: { + confirmed: true, + txHash: result.txHash, + }, + }) + window.dispatchEvent(event) + + pop() + return true + } catch (error) { + console.error('[MiniappDestroyConfirmJob] Destroy failed:', error) + return false + } finally { + setIsConfirming(false) + } + }) + + // 打开钱包锁验证 + push('WalletLockConfirmJob', { + title: t('transaction:destroyPage.title'), + }) + }, [isConfirming, currentWallet, chainConfig, asset, from, amount, pop, push, t]) + + const handleCancel = useCallback(() => { + const event = new CustomEvent('miniapp-destroy-confirm', { + detail: { confirmed: false }, + }) + window.dispatchEvent(event) + pop() + }, [pop]) + + return ( + +
+ {/* Handle */} +
+
+
+ + {/* Header */} +
+
+ {appIcon ? ( + {appName} + ) : ( + + )} +
+

+ {t('transaction:destroyPage.title')} +

+

+ {appName || t('common:unknownDApp')} {t('common:requestsDestroy', '请求销毁资产')} +

+
+ + {/* Content */} +
+ {/* Amount */} +
+ +
+ + {/* From address */} +
+
+ + {t('common:from', '来自')} + + +
+
+ + {/* Chain & Asset */} +
+ + {t('common:network', '网络')} + + {chain} +
+ + {/* Warning */} +
+ +

+ {t('transaction:destroyPage.warning')} +

+
+
+ + {/* Actions */} +
+ + +
+ + {/* Safe area */} +
+
+ + ) +} + +export const MiniappDestroyConfirmJob: ActivityComponentType = ({ + params, +}) => { + return ( + + + + ) +} diff --git a/src/stackflow/activities/sheets/index.ts b/src/stackflow/activities/sheets/index.ts index b39947719..b7040f658 100644 --- a/src/stackflow/activities/sheets/index.ts +++ b/src/stackflow/activities/sheets/index.ts @@ -21,5 +21,6 @@ export { WalletPickerJob } from "./WalletPickerJob"; export { SigningConfirmJob } from "./SigningConfirmJob"; export { PermissionRequestJob } from "./PermissionRequestJob"; export { MiniappTransferConfirmJob } from "./MiniappTransferConfirmJob"; +export { MiniappDestroyConfirmJob } from "./MiniappDestroyConfirmJob"; export { MiniappSignTransactionJob } from "./MiniappSignTransactionJob"; export { ChainSwitchConfirmJob } from "./ChainSwitchConfirmJob"; diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index 95c19ee62..44511a24d 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useFlow } from "../../stackflow"; import { WalletCardCarousel } from "@/components/wallet/wallet-card-carousel"; @@ -7,14 +7,24 @@ import { LoadingSpinner } from "@/components/common/loading-spinner"; import { MigrationRequiredView } from "@/components/common/migration-required-view"; import { GradientButton } from "@/components/common/gradient-button"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useWalletTheme } from "@/hooks/useWalletTheme"; import { useClipboard, useToast, useHaptics } from "@/services"; import { useBalanceQuery, useTransactionHistoryQuery } from "@/queries"; +import type { TokenInfo, TokenItemContext } from "@/components/token/token-item"; import { IconPlus, IconSend, IconQrcode, IconLineScan, + IconDotsVertical, + IconFlame, + IconArrowRight, } from "@tabler/icons-react"; import { useWallets, @@ -25,6 +35,8 @@ import { useHasWallet, useWalletInitialized, useChainConfigMigrationRequired, + useChainConfigState, + chainConfigSelectors, walletActions, } from "@/stores"; import type { TransactionInfo } from "@/components/transaction/transaction-item"; @@ -61,6 +73,11 @@ export function WalletTab() { const chainPreferences = useChainPreferences(); const selectedChainName = CHAIN_NAMES[selectedChain] ?? selectedChain; const tokens = useCurrentChainTokens(); + const chainConfigState = useChainConfigState(); + const chainConfig = chainConfigState.snapshot + ? chainConfigSelectors.getChainById(chainConfigState, selectedChain) + : null; + const mainAssetSymbol = chainConfig?.symbol; // 初始化钱包主题 useWalletTheme(); @@ -146,6 +163,49 @@ export function WalletTab() { [push] ); + // 资产操作菜单渲染 + const renderTokenActions = useCallback( + (token: TokenInfo, context: TokenItemContext) => { + return ( + + + + + + { + e.stopPropagation(); + push("SendActivity", { assetType: token.symbol }); + }} + > + + {t("home:wallet.transfer")} + + {context.canDestroy && ( + { + e.stopPropagation(); + push("DestroyActivity", { assetType: token.symbol, assetLocked: "true" }); + }} + className="text-destructive focus:text-destructive" + > + + {t("home:wallet.destroy")} + + )} + + + ); + }, + [push, t] + ); + // 需要迁移数据库 if (migrationRequired) { return ; @@ -242,6 +302,8 @@ export function WalletTab() { console.log("Token clicked:", token.symbol); }} onTransactionClick={handleTransactionClick} + mainAssetSymbol={mainAssetSymbol} + renderTokenActions={renderTokenActions} renderTransactionFooter={() => ( + )) + + render( + + ) + + expect(screen.getByTestId('custom-action')).toBeInTheDocument() + expect(renderActions).toHaveBeenCalledWith( + mockToken, + expect.objectContaining({ + chainType: 'bfmeta', + isBioforestChain: true, + isMainAsset: false, + canDestroy: true, + }) + ) + }) + + it('action click does not trigger parent onClick', () => { + const onClick = vi.fn() + const actionClick = vi.fn() + + const renderActions = () => ( + + ) + + render( + + ) + + fireEvent.click(screen.getByTestId('custom-action')) + + expect(actionClick).toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + }) + }) + + describe('context menu (onContextMenu)', () => { + it('should trigger onContextMenu when provided', () => { + const onContextMenu = vi.fn() + + render( + + ) + + const tokenItem = screen.getByTestId('token-item') + fireEvent.contextMenu(tokenItem) + + expect(onContextMenu).toHaveBeenCalledWith( + expect.any(Object), // event + mockToken, + expect.objectContaining({ + chainType: 'bfmeta', + isBioforestChain: true, + }) + ) + }) + + it('more button click should trigger onContextMenu', () => { + const onContextMenu = vi.fn() + + render( + + ) + + const moreButton = screen.getByLabelText(/more|更多/i) + fireEvent.click(moreButton) + + expect(onContextMenu).toHaveBeenCalled() + }) + + it('long press should trigger onContextMenu on touch devices', async () => { + const onContextMenu = vi.fn() + + render( + + ) + + const tokenItem = screen.getByTestId('token-item') + + // Simulate long press via touch events + fireEvent.touchStart(tokenItem) + + // Wait for long press timeout (typically 500ms) + await new Promise(resolve => setTimeout(resolve, 600)) + + fireEvent.touchEnd(tokenItem) + + expect(onContextMenu).toHaveBeenCalled() + }) + + it('should not show more button when onContextMenu is not provided', () => { + render( + + ) + + expect(screen.queryByLabelText(/more|操作/i)).not.toBeInTheDocument() + }) + }) + + describe('context for bioforest chains', () => { + it('canDestroy is true for non-main asset on bioforest chain', () => { + let capturedContext: TokenItemContext | null = null + + const renderActions = (_token: TokenInfo, context: TokenItemContext) => { + capturedContext = context + return null + } + + render( + + ) + + expect(capturedContext?.canDestroy).toBe(true) + expect(capturedContext?.isBioforestChain).toBe(true) + expect(capturedContext?.isMainAsset).toBe(false) + }) + + it('canDestroy is false for main asset', () => { + let capturedContext: TokenItemContext | null = null + + const renderActions = (_token: TokenInfo, context: TokenItemContext) => { + capturedContext = context + return null + } + + render( + + ) + + expect(capturedContext?.canDestroy).toBe(false) + expect(capturedContext?.isMainAsset).toBe(true) + }) + + it('canDestroy is false for non-bioforest chains', () => { + let capturedContext: TokenItemContext | null = null + + const renderActions = (_token: TokenInfo, context: TokenItemContext) => { + capturedContext = context + return null + } + + render( + + ) + + expect(capturedContext?.canDestroy).toBe(false) + expect(capturedContext?.isBioforestChain).toBe(false) + }) + }) +}) diff --git a/src/components/token/token-item.tsx b/src/components/token/token-item.tsx index a7545dd12..e85e299cf 100644 --- a/src/components/token/token-item.tsx +++ b/src/components/token/token-item.tsx @@ -1,5 +1,7 @@ +import * as React from 'react'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; +import { IconDotsVertical } from '@tabler/icons-react'; import { ChainIcon, TokenIcon, type ChainType } from '../wallet'; import { AmountDisplay, AnimatedAmount } from '../common'; import { currencies, useCurrency } from '@/stores'; @@ -52,6 +54,13 @@ interface TokenItemProps { renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined; /** Main asset symbol of the chain (used to determine isMainAsset) */ mainAssetSymbol?: string | undefined; + /** + * Context menu handler - triggered by: + * - Right click (desktop) + * - Long press (mobile) + * - More button click + */ + onContextMenu?: ((event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => void) | undefined; } // BioForest chain types that support asset destruction @@ -75,10 +84,15 @@ export function TokenItem({ testId, renderActions, mainAssetSymbol, + onContextMenu, }: TokenItemProps) { const isClickable = !!onClick; const { t } = useTranslation(['currency', 'common']); const currency = useCurrency(); + + // Long press support for mobile + const longPressTimerRef = React.useRef | null>(null); + const longPressTriggeredRef = React.useRef(false); // Compute context for renderActions const isBioforestChain = BIOFOREST_CHAINS.has(token.chain); @@ -94,6 +108,56 @@ export function TokenItem({ canDestroy, }; + // Context menu handlers + const handleContextMenu = React.useCallback((e: React.MouseEvent) => { + if (onContextMenu) { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(e, token, context); + } + }, [onContextMenu, token, context]); + + const handleMoreButtonClick = React.useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e, token, context); + }, [onContextMenu, token, context]); + + // Long press handlers for touch devices + const handleTouchStart = React.useCallback(() => { + if (!onContextMenu) return; + + longPressTriggeredRef.current = false; + longPressTimerRef.current = setTimeout(() => { + longPressTriggeredRef.current = true; + onContextMenu(null, token, context); + }, 500); // 500ms long press + }, [onContextMenu, token, context]); + + const handleTouchEnd = React.useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }, []); + + const handleTouchMove = React.useCallback(() => { + // Cancel long press if user moves finger + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }, []); + + // Cleanup timer on unmount + React.useEffect(() => { + return () => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + }; + }, []); + const shouldFetchRate = token.fiatValue !== undefined && currency !== 'USD'; const { data: exchangeRateData, @@ -127,6 +191,10 @@ export function TokenItem({ tabIndex={isClickable ? 0 : undefined} onClick={onClick} onKeyDown={isClickable ? (e) => e.key === 'Enter' && onClick?.() : undefined} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} aria-label={isClickable ? t('common:a11y.tokenDetails', { token: token.symbol }) : undefined} className={cn( '@container flex items-center gap-3 rounded-xl p-3 transition-colors', @@ -182,8 +250,21 @@ export function TokenItem({ )}
- {/* Custom actions slot */} - {renderActions && ( + {/* More button - visible when onContextMenu is provided */} + {onContextMenu && ( + + )} + + {/* Custom actions slot (deprecated: use onContextMenu instead) */} + {renderActions && !onContextMenu && (
e.stopPropagation()} diff --git a/src/components/token/token-list.tsx b/src/components/token/token-list.tsx index f6e413e70..2e6134c6f 100644 --- a/src/components/token/token-list.tsx +++ b/src/components/token/token-list.tsx @@ -16,10 +16,12 @@ interface TokenListProps { emptyAction?: React.ReactNode | undefined className?: string | undefined testId?: string | undefined - /** Render prop for custom actions per token item */ + /** Render prop for custom actions per token item (deprecated: use onContextMenu) */ renderActions?: ((token: TokenInfo, context: TokenItemContext) => React.ReactNode) | undefined /** Main asset symbol for the chain (used by renderActions context) */ mainAssetSymbol?: string | undefined + /** Context menu handler for token items */ + onContextMenu?: ((event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => void) | undefined } export function TokenList({ @@ -35,6 +37,7 @@ export function TokenList({ testId, renderActions, mainAssetSymbol, + onContextMenu, }: TokenListProps) { if (loading) { return @@ -68,6 +71,7 @@ export function TokenList({ loading={refreshing} renderActions={renderActions} mainAssetSymbol={mainAssetSymbol} + onContextMenu={onContextMenu} {...(onTokenClick && { onClick: () => onTokenClick(token) })} /> ))} diff --git a/src/components/wallet/wallet-address-portfolio-view.tsx b/src/components/wallet/wallet-address-portfolio-view.tsx index b36c59d41..ad950db19 100644 --- a/src/components/wallet/wallet-address-portfolio-view.tsx +++ b/src/components/wallet/wallet-address-portfolio-view.tsx @@ -28,8 +28,10 @@ export interface WalletAddressPortfolioViewProps { renderTransactionFooter?: () => React.ReactNode /** Main asset symbol for the chain (used for renderActions context) */ mainAssetSymbol?: string - /** Render prop for token item actions (e.g., dropdown menu) */ + /** Render prop for token item actions (deprecated: use onTokenContextMenu) */ renderTokenActions?: (token: TokenInfo, context: TokenItemContext) => React.ReactNode + /** Context menu handler for token items (right-click, long-press, more button) */ + onTokenContextMenu?: (event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => void className?: string testId?: string } @@ -51,6 +53,7 @@ export function WalletAddressPortfolioView({ renderTransactionFooter, mainAssetSymbol, renderTokenActions, + onTokenContextMenu, className, testId = 'wallet-address-portfolio', }: WalletAddressPortfolioViewProps) { @@ -85,6 +88,7 @@ export function WalletAddressPortfolioView({ emptyDescription={t('home:wallet.noAssetsOnChain', { chain: displayChainName })} mainAssetSymbol={mainAssetSymbol} renderActions={renderTokenActions} + onContextMenu={onTokenContextMenu} testId={`${testId}-token-list`} />
diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index 44511a24d..af267729d 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useFlow } from "../../stackflow"; import { WalletCardCarousel } from "@/components/wallet/wallet-card-carousel"; @@ -8,11 +8,11 @@ import { MigrationRequiredView } from "@/components/common/migration-required-vi import { GradientButton } from "@/components/common/gradient-button"; import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { useWalletTheme } from "@/hooks/useWalletTheme"; import { useClipboard, useToast, useHaptics } from "@/services"; import { useBalanceQuery, useTransactionHistoryQuery } from "@/queries"; @@ -22,7 +22,6 @@ import { IconSend, IconQrcode, IconLineScan, - IconDotsVertical, IconFlame, IconArrowRight, } from "@tabler/icons-react"; @@ -163,49 +162,35 @@ export function WalletTab() { [push] ); - // 资产操作菜单渲染 - const renderTokenActions = useCallback( - (token: TokenInfo, context: TokenItemContext) => { - return ( - - - - - - { - e.stopPropagation(); - push("SendActivity", { assetType: token.symbol }); - }} - > - - {t("home:wallet.transfer")} - - {context.canDestroy && ( - { - e.stopPropagation(); - push("DestroyActivity", { assetType: token.symbol, assetLocked: "true" }); - }} - className="text-destructive focus:text-destructive" - > - - {t("home:wallet.destroy")} - - )} - - - ); + // 资产操作菜单状态 + const [actionSheetOpen, setActionSheetOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState<{ token: TokenInfo; context: TokenItemContext } | null>(null); + + // 资产操作菜单触发 + const handleTokenContextMenu = useCallback( + (_event: React.MouseEvent | React.TouchEvent | null, token: TokenInfo, context: TokenItemContext) => { + haptics.impact("light"); + setSelectedToken({ token, context }); + setActionSheetOpen(true); }, - [push, t] + [haptics] ); + // 资产操作菜单项点击 + const handleTransfer = useCallback(() => { + if (selectedToken) { + setActionSheetOpen(false); + push("SendActivity", { assetType: selectedToken.token.symbol }); + } + }, [selectedToken, push]); + + const handleDestroy = useCallback(() => { + if (selectedToken) { + setActionSheetOpen(false); + push("DestroyActivity", { assetType: selectedToken.token.symbol, assetLocked: "true" }); + } + }, [selectedToken, push]); + // 需要迁移数据库 if (migrationRequired) { return ; @@ -303,7 +288,7 @@ export function WalletTab() { }} onTransactionClick={handleTransactionClick} mainAssetSymbol={mainAssetSymbol} - renderTokenActions={renderTokenActions} + onTokenContextMenu={handleTokenContextMenu} renderTransactionFooter={() => ( + {selectedToken?.context.canDestroy && ( + + )} +
+
+
); } From 553f1a46ff0b5df6173fb209ad6fd5a23305c7fb Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 16:00:33 +0800 Subject: [PATCH 10/11] feat(flow): add mode-aware str template for workflow descriptions - str`` tagged template returns () => Promise - str.scenarios(args) formats commands based on runtime mode (CLI vs MCP) - str.data(title, items) formats data sections by mode - Resolve async descriptions in meta.mcp.ts with WorkflowNameContext - Update task.workflow.ts to use str template for start workflow --- packages/flow/src/common/async-context.ts | 12 ++++++++++++ packages/flow/src/meta/meta.mcp.ts | 11 +++++++++-- scripts/agent-flow/workflows/task.workflow.ts | 8 +++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/flow/src/common/async-context.ts b/packages/flow/src/common/async-context.ts index dff1af3f0..41bc96413 100644 --- a/packages/flow/src/common/async-context.ts +++ b/packages/flow/src/common/async-context.ts @@ -123,11 +123,23 @@ export function isCliMode(): boolean { /** * Run function within specified runtime mode + * Supports both sync and async functions */ export function withRuntimeMode(mode: RuntimeMode, fn: () => R): R { return RuntimeContext.run({ mode }, fn); } +/** + * Run async function within specified runtime mode + * Use this for async functions to ensure context is preserved + */ +export async function withRuntimeModeAsync( + mode: RuntimeMode, + fn: () => Promise +): Promise { + return RuntimeContext.run({ mode }, fn); +} + // ============================================================================= // Workflow Name Context // ============================================================================= diff --git a/packages/flow/src/meta/meta.mcp.ts b/packages/flow/src/meta/meta.mcp.ts index 7d019c1ed..09e98466a 100644 --- a/packages/flow/src/meta/meta.mcp.ts +++ b/packages/flow/src/meta/meta.mcp.ts @@ -23,7 +23,8 @@ import { printMcpHelp, z, } from "../common/mcp/base-mcp.ts"; -import { withRuntimeMode } from "../common/async-context.ts"; +import { withRuntimeMode, WorkflowNameContext } from "../common/async-context.ts"; +import { resolveDescription } from "../common/workflow/base-workflow.ts"; const MCP_NAME = "meta"; @@ -145,7 +146,13 @@ async function getWorkflowInfo( else if (hasAi) mode = "ai"; else if (hasSubflows || hasDirectCommands) mode = "programmatic"; - return { description: workflow.meta.description, mode }; + // Resolve description with workflow name context + const description = await WorkflowNameContext.run( + workflow.meta.name, + () => resolveDescription(workflow.meta.description) + ); + + return { description, mode }; } } catch { // Ignore import errors diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index 159eedbc4..5b51b8a78 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -44,6 +44,7 @@ import { createRouter, defineWorkflow, } from "../../../packages/flow/src/common/workflow/base-workflow.ts"; +import { str } from "../../../packages/flow/src/common/async-context.ts"; import { createIssue, createPr, @@ -125,7 +126,12 @@ ${desc} */ const startWorkflow = defineWorkflow({ name: "start", - description: "启动新任务 (Issue + Worktree + Draft PR,不触发 CI)", + description: str`启动新任务 (Issue + Worktree + Draft PR,不触发 CI) + +## When to Use +- 启动服务开发 → ${str.scenarios(["--type", "service", "--title", "Feature"])} +- 列出可用标签 → ${str.scenarios(["--list-labels"])} +- 自动创建标签 → ${str.scenarios(["--type", "ui", "--create-labels"])}`, args: { title: { type: "string", description: "任务标题", required: false }, type: { From ef3ec752aac54a98f7259e91d12801e48ecb9e86 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Fri, 9 Jan 2026 16:07:57 +0800 Subject: [PATCH 11/11] test(storybook-e2e): add TokenItem context menu visual tests - Add WithContextMenu and ContextMenuList stories to token-item.stories.tsx - Fix DestroyPage story to remove non-existent imports - Create token-item-context-menu.spec.ts with 3 tests for context menu feature - Add baseline screenshots for visual regression testing --- src/components/token/token-item.stories.tsx | 50 ++++++ src/pages/destroy/destroy.stories.tsx | 29 +--- ...context-menu.spec.ts-context-menu-list.png | Bin 0 -> 17581 bytes ...context-menu.spec.ts-with-context-menu.png | Bin 0 -> 8879 bytes storybook-e2e/token-item-context-menu.spec.ts | 164 ++++++++++++++++++ 5 files changed, 218 insertions(+), 25 deletions(-) create mode 100644 storybook-e2e/__screenshots__/Mobile-Chrome/token-item-context-menu.spec.ts-context-menu-list.png create mode 100644 storybook-e2e/__screenshots__/Mobile-Chrome/token-item-context-menu.spec.ts-with-context-menu.png create mode 100644 storybook-e2e/token-item-context-menu.spec.ts diff --git a/src/components/token/token-item.stories.tsx b/src/components/token/token-item.stories.tsx index d18201e20..cde162a83 100644 --- a/src/components/token/token-item.stories.tsx +++ b/src/components/token/token-item.stories.tsx @@ -122,3 +122,53 @@ export const Responsive: Story = { }, }, }; + +export const WithContextMenu: Story = { + args: { + token: mockUSDT, + onClick: () => alert('Clicked USDT'), + onContextMenu: (event, token, context) => { + alert(`Context menu for ${token.symbol}\nCan destroy: ${context.canDestroy}`); + }, + mainAssetSymbol: 'ETH', // USDT is not main asset, so canDestroy = true for bioforest chains + }, + parameters: { + docs: { + description: { + story: '带有更多操作按钮的代币项,点击按钮、右键点击或长按触发上下文菜单', + }, + }, + }, +}; + +export const ContextMenuList: Story = { + render: () => ( +
+ {}} + onContextMenu={(e, token) => alert(`Menu: ${token.symbol}`)} + mainAssetSymbol="ETH" + /> + {}} + onContextMenu={(e, token) => alert(`Menu: ${token.symbol}`)} + mainAssetSymbol="ETH" + /> + {}} + onContextMenu={(e, token) => alert(`Menu: ${token.symbol}`)} + mainAssetSymbol="TRX" + /> +
+ ), + parameters: { + docs: { + description: { + story: '多个代币项带有上下文菜单,注意主资产(TRX)的 canDestroy 为 false', + }, + }, + }, +}; diff --git a/src/pages/destroy/destroy.stories.tsx b/src/pages/destroy/destroy.stories.tsx index ad7440afa..daaf01eb8 100644 --- a/src/pages/destroy/destroy.stories.tsx +++ b/src/pages/destroy/destroy.stories.tsx @@ -1,20 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react' import { DestroyPage } from './index' -import { StorybookProviders } from '@/test/storybook-providers' -import { StackflowStoryWrapper } from '@/test/stackflow-story-wrapper' const meta: Meta = { title: 'Pages/DestroyPage', component: DestroyPage, decorators: [ (Story) => ( - - -
- -
-
-
+
+ +
), ], parameters: { @@ -29,20 +23,5 @@ export default meta type Story = StoryObj export const Default: Story = { - name: 'Default (No Wallet)', -} - -export const WithMockData: Story = { - name: 'With Mock Wallet', - parameters: { - mockData: { - hasWallet: true, - selectedChain: 'bfmeta', - tokens: [ - { symbol: 'BFM', name: 'BFMeta', balance: '1000', decimals: 8 }, - { symbol: 'TEST', name: 'Test Token', balance: '500', decimals: 8 }, - { symbol: 'USDT', name: 'Tether USD', balance: '1000', decimals: 8 }, - ], - }, - }, + name: 'Default', } diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/token-item-context-menu.spec.ts-context-menu-list.png b/storybook-e2e/__screenshots__/Mobile-Chrome/token-item-context-menu.spec.ts-context-menu-list.png new file mode 100644 index 0000000000000000000000000000000000000000..42e068ffaf523bb03bbb0554a0de8f94c52e8356 GIT binary patch literal 17581 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 literal 0 HcmV?d00001 diff --git a/storybook-e2e/__screenshots__/Mobile-Chrome/token-item-context-menu.spec.ts-with-context-menu.png b/storybook-e2e/__screenshots__/Mobile-Chrome/token-item-context-menu.spec.ts-with-context-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..7204d9a30c4a59a18e9b2bb064b5311cfed9ca6e GIT binary patch literal 8879 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 literal 0 HcmV?d00001 diff --git a/storybook-e2e/token-item-context-menu.spec.ts b/storybook-e2e/token-item-context-menu.spec.ts new file mode 100644 index 000000000..415ae7cc1 --- /dev/null +++ b/storybook-e2e/token-item-context-menu.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from '@playwright/test' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const storybookStaticDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../storybook-static' +) + +function getContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() + switch (ext) { + case '.html': + return 'text/html; charset=utf-8' + case '.js': + return 'text/javascript; charset=utf-8' + case '.css': + return 'text/css; charset=utf-8' + case '.json': + return 'application/json; charset=utf-8' + case '.svg': + return 'image/svg+xml' + case '.png': + return 'image/png' + case '.jpg': + case '.jpeg': + return 'image/jpeg' + case '.webp': + return 'image/webp' + case '.ico': + return 'image/x-icon' + case '.woff': + return 'font/woff' + case '.woff2': + return 'font/woff2' + case '.ttf': + return 'font/ttf' + case '.map': + return 'application/json; charset=utf-8' + default: + return 'application/octet-stream' + } +} + +async function startStorybookServer(): Promise<{ server: Server; baseUrl: string }> { + await stat(path.join(storybookStaticDir, 'index.html')) + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + const rawPath = decodeURIComponent(url.pathname) + const relativePath = rawPath === '/' ? '/index.html' : rawPath + const filePath = path.normalize(path.join(storybookStaticDir, relativePath)) + + if (!filePath.startsWith(storybookStaticDir)) { + res.statusCode = 403 + res.end('Forbidden') + return + } + + try { + const stats = await stat(filePath) + if (!stats.isFile()) { + res.statusCode = 404 + res.end('Not found') + return + } + + res.statusCode = 200 + res.setHeader('Content-Type', getContentType(filePath)) + createReadStream(filePath).pipe(res) + } catch { + res.statusCode = 404 + res.end('Not found') + } + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Unexpected server address') + + return { server, baseUrl: `http://127.0.0.1:${address.port}` } +} + +function storyUrl(baseUrl: string, storyId: string): string { + return `${baseUrl}/?path=/story/${storyId}` +} + +test.describe('TokenItem Context Menu Storybook', () => { + let server: Server + let baseUrl: string + + test.beforeAll(async () => { + const started = await startStorybookServer() + server = started.server + baseUrl = started.baseUrl + }) + + test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + }) + + test('WithContextMenu renders more button', async ({ page }) => { + const frame = page.frameLocator('#storybook-preview-iframe') + + await page.goto(storyUrl(baseUrl, 'token-tokenitem--with-context-menu')) + + // Wait for the token item to be visible + await expect(frame.getByText('USDT')).toBeVisible() + + // Check that more button is visible + const moreButton = frame.getByRole('button', { name: /更多操作/i }) + await expect(moreButton).toBeVisible() + + // Take screenshot + await expect(frame.locator('#storybook-root')).toHaveScreenshot('with-context-menu.png') + }) + + test('ContextMenuList renders multiple items with more buttons', async ({ page }) => { + const frame = page.frameLocator('#storybook-preview-iframe') + + await page.goto(storyUrl(baseUrl, 'token-tokenitem--context-menu-list')) + + // Wait for tokens to be visible using more specific selectors + await expect(frame.locator('p.font-medium').filter({ hasText: 'USDT' })).toBeVisible() + await expect(frame.locator('p.font-medium').filter({ hasText: 'ETH' })).toBeVisible() + await expect(frame.locator('p.font-medium').filter({ hasText: 'TRX' })).toBeVisible() + + // Check that more buttons are visible for all items + const moreButtons = frame.getByRole('button', { name: /更多操作/i }) + await expect(moreButtons).toHaveCount(3) + + // Take screenshot + await expect(frame.locator('#storybook-root')).toHaveScreenshot('context-menu-list.png') + }) + + test('clicking more button triggers dialog', async ({ page }) => { + const frame = page.frameLocator('#storybook-preview-iframe') + + await page.goto(storyUrl(baseUrl, 'token-tokenitem--with-context-menu')) + + // Wait for the token item to be visible + await expect(frame.getByText('USDT')).toBeVisible() + + // Set up dialog handler + let dialogMessage = '' + page.on('dialog', async (dialog) => { + dialogMessage = dialog.message() + await dialog.accept() + }) + + // Click the more button + const moreButton = frame.getByRole('button', { name: /更多操作/i }) + await moreButton.click() + + // Verify dialog was triggered with correct content + expect(dialogMessage).toContain('USDT') + expect(dialogMessage).toContain('Can destroy') + }) +})