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
)}
+
+ {/* 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 ? (
+

+ ) : (
+
+ )}
+
+
+ {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={() => (