diff --git a/docs/white-book/11-DApp-Guide/02-Connectivity/01-Bio-SDK-Communication.md b/docs/white-book/11-DApp-Guide/02-Connectivity/01-Bio-SDK-Communication.md index 07f0fc7e6..571621d09 100644 --- a/docs/white-book/11-DApp-Guide/02-Connectivity/01-Bio-SDK-Communication.md +++ b/docs/white-book/11-DApp-Guide/02-Connectivity/01-Bio-SDK-Communication.md @@ -177,10 +177,16 @@ import { bio } from '@bioforest/bio-sdk'; // 请求钱包地址 const accounts = await bio.wallet.requestAccounts(); -// 签名交易 -const txHash = await bio.wallet.sendTransaction({ - to: '0x...', - value: '1000000000000000000', +// 发送交易(amount 使用 raw 最小单位整数字符串) +const txResult = await bio.request({ + method: 'bio_sendTransaction', + params: [{ + from: 'bFxxxxxxxxxxxxxxxxxxxx', + to: 'bNxxxxxxxxxxxxxxxxxxxx', + amount: '1000000000', // raw(例如 decimals=8 => 10.00000000) + chain: 'BFMetaV2', + asset: 'USDT', + }], }); // 键值存储 @@ -259,6 +265,12 @@ class BioSDK { } ``` +## 金额语义(重要) + +- 所有交易相关接口(`bio_sendTransaction` / `bio_createTransaction` / `bio_destroyAsset`)的 `amount` 均使用 **raw 最小单位整数字符串**。 +- 禁止传入格式化小数字符串(例如 `10.00000000`)。 +- 详见:[`02-Amount-Semantics-Standard.md`](./02-Amount-Semantics-Standard.md) + ## 安全考虑 1. **来源验证**: Host 端应验证 `event.source` 确保消息来自预期的 iframe diff --git a/docs/white-book/11-DApp-Guide/02-Connectivity/02-Amount-Semantics-Standard.md b/docs/white-book/11-DApp-Guide/02-Connectivity/02-Amount-Semantics-Standard.md new file mode 100644 index 000000000..18d4e5794 --- /dev/null +++ b/docs/white-book/11-DApp-Guide/02-Connectivity/02-Amount-Semantics-Standard.md @@ -0,0 +1,114 @@ +# 02. MiniApp 交易金额语义标准(Raw Amount Standard) + +> Last Updated: 2026-02-12 +> Status: Active(生态升级基线) + +本文定义 MiniApp 与 KeyApp 交互中所有“链上金额字段”的统一语义,避免“展示金额与真实广播金额不一致”的高风险问题。 + +--- + +## 1. 统一规则(必须遵守) + +### 1.1 金额字段一律使用 raw(最小单位整数) + +- `amount` 必须是 **十进制整数字符串**(`^\d+$`) +- 表示链最小单位(如 USDT 8 位精度时,`1000000000` 表示 `10.00000000`) +- 禁止传入格式化小数字符串(例如 `10.00000000`) + +### 1.2 展示与签名/广播职责分离 + +- 展示层:根据 `decimals` 把 raw 转为人类可读金额 +- 交易层:签名与广播始终使用 raw +- 严禁在显示修复时改动实际广播语义 + +### 1.3 适用接口(第一批) + +- `bio_sendTransaction.params.amount` +- `bio_createTransaction.params.amount` +- `bio_destroyAsset.params.amount` + +--- + +## 2. 风险背景(为什么必须统一) + +当调用方把 raw 当 formatted,或 Host 把 formatted 当 raw,会导致: + +- 面板显示金额被放大/缩小(误导用户) +- 签名/广播金额与用户预期不一致 +- 后端入库与链上数据出现语义冲突(含重复提交与对账困难) + +这是高风险交易语义问题,不是纯 UI 问题。 + +--- + +## 3. 当前审计结论(KeyApp 内置应用) + +### 3.1 已符合或基本符合 + +- `xin.dweb.rwahub`:调用 `bio_sendTransaction` 前使用整数运算构造 raw(BigInt 路径) +- `xin.dweb.biobridge`(forge/redemption 主路径):在有精度上下文时将用户输入转换为 raw 再调用 + +### 3.2 需升级 + +- `xin.dweb.teleport`:当前 `bio_createTransaction` 调用路径仍可能传格式化小数字符串(示例:补 `.0`) + +### 3.3 Host 侧需对齐项 + +- `bio_sendTransaction` 已按 raw 语义处理(现状) +- `bio_destroyAsset` 对话框仍存在 formatted 假设(需要改为 raw 语义) +- `bio_createTransaction` 当前存在“自动识别 raw/formatted”路径(应收敛为单一 raw 语义) + +--- + +## 4. 升级计划(执行顺序) + +### Phase 0:标准冻结(立即) + +- 白皮书明确 raw-only 规则(本文) +- 对外同步“禁止 formatted amount” + +### Phase 1:Host 收敛(优先) + +- `bio_destroyAsset` 切换为 raw 解析与展示 +- `bio_createTransaction` 去除双语义自动判断,统一 raw-only +- 参数不合规时统一返回 `INVALID_PARAMS` + +### Phase 2:内置应用对齐 + +- 升级 `xin.dweb.teleport`,确保传入 raw +- 回归验证 `biobridge`、`rwahub` 多笔交易流程 + +### Phase 3:生态外部应用迁移 + +- 发布迁移窗口和截止版本 +- 要求每个应用提交“amount 字段转换点”自检清单 +- 逐步开启严格校验(不再接收 formatted) + +--- + +## 5. 测试与验收基线 + +### 5.1 最小验收样例 + +- 输入 `amount="1000000000"`, `decimals=8`:展示 `10.00000000` +- 输入 `amount="10.00000000"`:直接 `INVALID_PARAMS`(严格模式) +- 广播上链金额必须保持 `raw=1000000000` + +### 5.2 回归重点 + +- 多笔交易队列(FIFO,不互相覆盖) +- 手势/支付密码步骤下金额不漂移 +- 广播失败时状态文案与实际阶段一致 + +--- + +## 6. 迁移沟通模板(摘要) + +对生态方统一口径: + +1. `amount` 改为 raw 整数字符串 +2. UI 层自行处理 formatted ↔ raw +3. 不再依赖 Host 自动兼容 formatted + +完整可执行提示词见:`/.chat/2026-02-12-miniapp-amount-raw-upgrade-plan.md` + diff --git a/docs/white-book/11-DApp-Guide/02-Connectivity/README.md b/docs/white-book/11-DApp-Guide/02-Connectivity/README.md index aba3bdb31..b7c1b27b8 100644 --- a/docs/white-book/11-DApp-Guide/02-Connectivity/README.md +++ b/docs/white-book/11-DApp-Guide/02-Connectivity/README.md @@ -13,19 +13,24 @@ The BioBridge Protocol is the communication layer between the Miniapp (running i ```json { "id": "uuid-v4", - "method": "wallet_requestAccounts", + "method": "bio_requestAccounts", "params": [], "jsonrpc": "2.0" } ``` -### Supported Methods +### Supported Methods (Core) -- `wallet_requestAccounts`: Request user address. -- `wallet_sendTransaction`: Request transaction signing. -- `wallet_signMessage`: Request message signing. -- `kv_get`: Read from isolated storage. -- `kv_set`: Write to isolated storage. +- `bio_requestAccounts`: Request user address list. +- `bio_createTransaction`: Build unsigned transaction. +- `bio_signTransaction`: Sign unsigned transaction. +- `bio_sendTransaction`: Request transfer authorization and broadcast. +- `bio_destroyAsset`: Request destroy authorization. + +### Amount Semantics Standard + +- See: [`02-Amount-Semantics-Standard.md`](./02-Amount-Semantics-Standard.md) +- Key rule: all transaction `amount` fields use raw integer string (minimum unit). ## Network Access diff --git a/docs/white-book/11-DApp-Guide/README.md b/docs/white-book/11-DApp-Guide/README.md index d1f1ea274..f03fbf448 100644 --- a/docs/white-book/11-DApp-Guide/README.md +++ b/docs/white-book/11-DApp-Guide/README.md @@ -13,3 +13,4 @@ - **02-Connectivity (连接性)** - [README.md](./02-Connectivity/README.md) - 连接性概述 - [01-Bio-SDK-Communication.md](./02-Connectivity/01-Bio-SDK-Communication.md) - Bio-SDK 通讯机制 + - [02-Amount-Semantics-Standard.md](./02-Connectivity/02-Amount-Semantics-Standard.md) - 交易金额语义标准(Raw Amount) diff --git a/miniapps/teleport/src/App.tsx b/miniapps/teleport/src/App.tsx index f140fee3b..f26086f56 100644 --- a/miniapps/teleport/src/App.tsx +++ b/miniapps/teleport/src/App.tsx @@ -159,8 +159,20 @@ const CHAIN_COLORS: Record = { const normalizeInternalChainName = (value: string): InternalChainName => value.toUpperCase() as InternalChainName; -const normalizeInputAmount = (value: string) => - value.includes('.') ? value : `${value}.0`; +const normalizeInputAmount = (value: string, decimals: number): string => { + const normalized = value.trim(); + if (!/^\d+(\.\d+)?$/.test(normalized)) { + throw new Error('Invalid amount format'); + } + + const [intPart, fractionalPart = ''] = normalized.split('.'); + if (fractionalPart.length > decimals) { + throw new Error('Amount precision exceeds decimals'); + } + + const raw = `${intPart}${fractionalPart.padEnd(decimals, '0')}`.replace(/^0+(?=\d)/, ''); + return raw.length > 0 ? raw : '0'; +}; const formatMinAmount = (decimals: number) => { if (decimals <= 0) return '1'; @@ -334,7 +346,7 @@ export default function App() { { from: sourceAccount.address, to: selectedAsset.recipientAddress, - amount: normalizeInputAmount(amount), + amount: normalizeInputAmount(amount, selectedAsset.decimals), chain: sourceAccount.chain, asset: selectedAsset.assetType, ...(remark ? { remark } : {}), diff --git a/packages/bio-sdk/src/types.ts b/packages/bio-sdk/src/types.ts index 9d8d51677..7f74afc6d 100644 --- a/packages/bio-sdk/src/types.ts +++ b/packages/bio-sdk/src/types.ts @@ -12,11 +12,21 @@ export interface BioAccount { publicKey: string } +/** + * Raw amount string (minimum unit, integer only) + * + * Example: + * - decimals = 8 + * - human amount 10.00000000 => raw amount "1000000000" + */ +export type RawAmountString = string + /** Transfer parameters */ export interface TransferParams { from: string to: string - amount: string + /** amount uses raw minimum-unit integer string */ + amount: RawAmountString chain: string asset?: string } @@ -94,7 +104,12 @@ export interface BioMethods { /** Sign an unsigned transaction (requires user confirmation) */ bio_signTransaction: (params: { from: string; chain: string; unsignedTx: BioUnsignedTransaction }) => Promise - /** Send a transaction */ + /** + * Send a transaction + * + * Note: + * - params.amount must be raw integer string (minimum unit) + */ bio_sendTransaction: (params: TransferParams) => Promise<{ txHash: string }> /** Get current chain ID */ diff --git a/src/services/ecosystem/__tests__/destroy-handler.test.ts b/src/services/ecosystem/__tests__/destroy-handler.test.ts new file mode 100644 index 000000000..88946c2b8 --- /dev/null +++ b/src/services/ecosystem/__tests__/destroy-handler.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BioErrorCodes } from '../types'; +import { handleDestroyAsset, setDestroyDialog } from '../handlers/destroy'; + +describe('handleDestroyAsset amount semantics', () => { + const context = { + appId: 'test-miniapp', + appName: 'Test Miniapp', + appIcon: 'https://miniapp.example/icon.png', + origin: 'https://test.app', + permissions: ['bio_destroyAsset'], + }; + + beforeEach(() => { + setDestroyDialog(null); + }); + + it('rejects formatted amount before opening dialog', async () => { + const dialog = vi.fn(async () => ({ txHash: 'tx-hash' })); + setDestroyDialog(dialog); + + await expect( + handleDestroyAsset( + { + from: 'b_sender', + amount: '10.00000000', + chain: 'bfmeta', + asset: 'BFM', + }, + context, + ), + ).rejects.toMatchObject({ + code: BioErrorCodes.INVALID_PARAMS, + message: 'Invalid amount: expected raw integer string', + }); + + expect(dialog).not.toHaveBeenCalled(); + }); + + it('accepts raw amount and opens dialog', async () => { + const dialog = vi.fn(async () => ({ txHash: 'tx-hash' })); + setDestroyDialog(dialog); + + const result = await handleDestroyAsset( + { + from: 'b_sender', + amount: '1000000000', + chain: 'bfmeta', + asset: 'BFM', + }, + context, + ); + + expect(result).toEqual({ txHash: 'tx-hash' }); + expect(dialog).toHaveBeenCalledWith( + expect.objectContaining({ + amount: '1000000000', + }), + ); + }); +}); diff --git a/src/services/ecosystem/__tests__/raw-amount.test.ts b/src/services/ecosystem/__tests__/raw-amount.test.ts new file mode 100644 index 000000000..d4193d7d5 --- /dev/null +++ b/src/services/ecosystem/__tests__/raw-amount.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { isRawAmountString, parseRawAmount } from '../raw-amount'; + +describe('raw amount helpers', () => { + it('accepts integer raw amount string', () => { + expect(isRawAmountString('1000000000')).toBe(true); + expect(isRawAmountString(' 1000000000 ')).toBe(true); + }); + + it('rejects formatted and non-decimal values', () => { + expect(isRawAmountString('10.00000000')).toBe(false); + expect(isRawAmountString('-100')).toBe(false); + expect(isRawAmountString('1e9')).toBe(false); + expect(isRawAmountString('0x10')).toBe(false); + }); + + it('parses raw amount with decimals correctly', () => { + const amount = parseRawAmount('1000000000', 8, 'USDT'); + expect(amount.toRawString()).toBe('1000000000'); + expect(amount.toFormatted({ trimTrailingZeros: false })).toBe('10.00000000'); + }); + + it('throws for non-raw amount', () => { + expect(() => parseRawAmount('10.00000000', 8, 'USDT')).toThrow('Invalid raw amount'); + }); +}); diff --git a/src/services/ecosystem/__tests__/transaction-handler-amount.test.ts b/src/services/ecosystem/__tests__/transaction-handler-amount.test.ts new file mode 100644 index 000000000..033d229bf --- /dev/null +++ b/src/services/ecosystem/__tests__/transaction-handler-amount.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BioErrorCodes } from '../types'; + +describe('handleCreateTransaction amount semantics', () => { + const context = { + appId: 'test-miniapp', + appName: 'Test Miniapp', + origin: 'https://test.app', + permissions: ['bio_createTransaction'], + }; + + let handleCreateTransaction: typeof import('../handlers/transaction').handleCreateTransaction; + let buildTransactionMock: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + + buildTransactionMock = vi.fn(async (intent: { type: string; amount: { toRawString: () => string } }) => ({ + chainId: 'bfmeta', + intentType: intent.type, + data: { + amountRaw: intent.amount.toRawString(), + }, + })); + + vi.doMock('@/stores', () => ({ + chainConfigActions: { + initialize: vi.fn(async () => undefined), + }, + chainConfigSelectors: { + getChainById: vi.fn((_: unknown, chainId: string) => ({ + id: chainId, + symbol: 'BFM', + decimals: 8, + chainKind: 'bioforest', + })), + }, + chainConfigStore: { + state: { + snapshot: {}, + }, + }, + walletStore: { + state: { + wallets: [ + { + id: 'wallet-1', + chainAddresses: [ + { + chain: 'bfmeta', + address: 'b_sender', + }, + ], + }, + ], + }, + }, + })); + + vi.doMock('@/services/chain-adapter/providers', () => ({ + getChainProvider: vi.fn(() => ({ + supportsBuildTransaction: true, + buildTransaction: buildTransactionMock, + })), + createChainProvider: vi.fn(() => ({ + supportsBuildTransaction: true, + buildTransaction: buildTransactionMock, + })), + })); + + const module = await import('../handlers/transaction'); + handleCreateTransaction = module.handleCreateTransaction; + }); + + afterEach(() => { + vi.doUnmock('@/stores'); + vi.doUnmock('@/services/chain-adapter/providers'); + }); + + it('rejects formatted amount', async () => { + await expect( + handleCreateTransaction( + { + from: 'b_sender', + to: 'b_receiver', + amount: '10.00000000', + chain: 'bfmeta', + asset: 'BFM', + }, + context, + ), + ).rejects.toMatchObject({ + code: BioErrorCodes.INVALID_PARAMS, + message: 'Invalid amount: expected raw integer string', + }); + }); + + it('accepts raw amount and keeps raw semantics', async () => { + const result = await handleCreateTransaction( + { + from: 'b_sender', + to: 'b_receiver', + amount: '1000000000', + chain: 'bfmeta', + asset: 'BFM', + }, + context, + ); + + expect(buildTransactionMock).toHaveBeenCalledTimes(1); + const intent = buildTransactionMock.mock.calls[0]?.[0] as { amount: { toRawString: () => string } }; + expect(intent.amount.toRawString()).toBe('1000000000'); + expect(result).toEqual({ + chainId: 'bfmeta', + intentType: 'transfer', + data: { + amountRaw: '1000000000', + }, + }); + }); +}); diff --git a/src/services/ecosystem/__tests__/transfer-handler.test.ts b/src/services/ecosystem/__tests__/transfer-handler.test.ts index c2f4add55..69b9be369 100644 --- a/src/services/ecosystem/__tests__/transfer-handler.test.ts +++ b/src/services/ecosystem/__tests__/transfer-handler.test.ts @@ -15,7 +15,7 @@ const baseContext = { const baseParams: EcosystemTransferParams = { from: 'b_sender', to: 'b_receiver', - amount: '1.25', + amount: '125000000', chain: 'bfmeta', } @@ -25,6 +25,37 @@ describe('handleSendTransaction', () => { setTransferDialog(null) }) + it('rejects formatted amount before opening dialog', async () => { + const showTransferDialog = vi.fn(async () => ({ + txHash: 'tx-hash-ctx-icon', + txId: 'tx-hash-ctx-icon', + transaction: { hash: 'tx-hash-ctx-icon' }, + })) + + HandlerContext.register(baseContext.appId, { + showWalletPicker: async () => null, + getConnectedAccounts: () => [], + showSigningDialog: async () => null, + showTransferDialog, + showSignTransactionDialog: async () => null, + }) + + await expect( + handleSendTransaction( + { + ...baseParams, + amount: '1.25', + }, + baseContext, + ), + ).rejects.toMatchObject({ + code: BioErrorCodes.INVALID_PARAMS, + message: 'Invalid amount: expected raw integer string', + }) + + expect(showTransferDialog).not.toHaveBeenCalled() + }) + it('passes miniapp icon from handler context', async () => { const showTransferDialog = vi.fn(async () => ({ txHash: 'tx-hash-ctx-icon', diff --git a/src/services/ecosystem/handlers/destroy.ts b/src/services/ecosystem/handlers/destroy.ts index c1ef4fb42..70b62a9c1 100644 --- a/src/services/ecosystem/handlers/destroy.ts +++ b/src/services/ecosystem/handlers/destroy.ts @@ -6,6 +6,7 @@ import type { MethodHandler, EcosystemDestroyParams } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext, type MiniappInfo, toMiniappInfo } from './context' import { enqueueMiniappSheet } from '../sheet-queue' +import { isRawAmountString } from '../raw-amount' // 兼容旧 API let _showDestroyDialog: ((params: EcosystemDestroyParams & { app: MiniappInfo }) => Promise<{ txHash: string } | null>) | null = null @@ -31,6 +32,13 @@ export const handleDestroyAsset: MethodHandler = async (params, context) => { ) } + if (!isRawAmountString(opts.amount)) { + throw Object.assign( + new Error('Invalid amount: expected raw integer string'), + { code: BioErrorCodes.INVALID_PARAMS } + ) + } + const showDestroyDialog = getDestroyDialog(context.appId) if (!showDestroyDialog) { throw Object.assign(new Error('Destroy dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR }) diff --git a/src/services/ecosystem/handlers/transaction.ts b/src/services/ecosystem/handlers/transaction.ts index 70af7c0a5..fbb12f114 100644 --- a/src/services/ecosystem/handlers/transaction.ts +++ b/src/services/ecosystem/handlers/transaction.ts @@ -10,13 +10,13 @@ import { BioErrorCodes } from '../types' import { HandlerContext, type SignTransactionParams, toMiniappInfo } from './context' import { enqueueMiniappSheet } from '../sheet-queue' -import { Amount } from '@/types/amount' import { chainConfigActions, chainConfigSelectors, chainConfigStore, walletStore } from '@/stores' import { createChainProvider, getChainProvider } from '@/services/chain-adapter/providers' import { hexToBytes } from '@noble/hashes/utils.js' import { deriveKey } from '@/lib/crypto/derivation' import { createBioforestKeypair, publicKeyToBioforestAddress } from '@/lib/crypto' import { normalizeTronAddress } from '@/services/chain-adapter/tron/address' +import { parseRawAmount } from '../raw-amount' function findWalletIdByAddress(chainId: string, address: string): string | null { const wallets = walletStore.state.wallets @@ -92,7 +92,12 @@ export const handleCreateTransaction: MethodHandler = async (params, _context) = const assetDecimals = tokenAddress && typeof opts.assetDecimals === 'number' ? opts.assetDecimals : chainConfig.decimals - const amount = Amount.parse(opts.amount, assetDecimals, assetSymbol) + let amount + try { + amount = parseRawAmount(opts.amount, assetDecimals, assetSymbol) + } catch { + throw Object.assign(new Error('Invalid amount: expected raw integer string'), { code: BioErrorCodes.INVALID_PARAMS }) + } const chainProvider = tokenAddress && chainConfig.chainKind === 'tron' ? createChainProvider(chainConfig.id) diff --git a/src/services/ecosystem/handlers/transfer.ts b/src/services/ecosystem/handlers/transfer.ts index c69b796c0..1f44a08e8 100644 --- a/src/services/ecosystem/handlers/transfer.ts +++ b/src/services/ecosystem/handlers/transfer.ts @@ -6,6 +6,7 @@ import type { MethodHandler, EcosystemTransferParams } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext, type MiniappInfo, type TransferDialogResult, toMiniappInfo } from './context' import { enqueueMiniappSheet } from '../sheet-queue' +import { isRawAmountString } from '../raw-amount' // 兼容旧 API let _showTransferDialog: ((params: EcosystemTransferParams & { app: MiniappInfo }) => Promise) | null = null @@ -56,6 +57,13 @@ export const handleSendTransaction: MethodHandler = async (params, context) => { ) } + if (!isRawAmountString(opts.amount)) { + throw Object.assign( + new Error('Invalid amount: expected raw integer string'), + { code: BioErrorCodes.INVALID_PARAMS } + ) + } + const showTransferDialog = getTransferDialog(context.appId) if (!showTransferDialog) { throw Object.assign(new Error('Transfer dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR }) diff --git a/src/services/ecosystem/raw-amount.ts b/src/services/ecosystem/raw-amount.ts new file mode 100644 index 000000000..ca2122d4d --- /dev/null +++ b/src/services/ecosystem/raw-amount.ts @@ -0,0 +1,15 @@ +import { Amount } from '@/types/amount'; + +const RAW_AMOUNT_PATTERN = /^\d+$/; + +export function isRawAmountString(value: string): boolean { + return RAW_AMOUNT_PATTERN.test(value.trim()); +} + +export function parseRawAmount(value: string, decimals: number, symbol: string): Amount { + const normalized = value.trim(); + if (!RAW_AMOUNT_PATTERN.test(normalized)) { + throw new Error('Invalid raw amount'); + } + return Amount.fromRaw(normalized, decimals, symbol); +} diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index 26f782035..d67544396 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -45,7 +45,7 @@ export interface BioAccount { export interface EcosystemTransferParams { from: string; to: string; - amount: string; // RPC 参数是字符串 + amount: string; // RPC 参数:raw 最小单位整数字符串 chain: string; asset?: string; /** 交易类型(默认 transfer) */ @@ -64,7 +64,7 @@ export interface EcosystemTransferParams { */ export interface EcosystemDestroyParams { from: string; - amount: string; + amount: string; // raw 最小单位整数字符串 chain: string; asset: string; } diff --git a/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx index e7b24e67e..0a6cdc0f1 100644 --- a/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx +++ b/src/stackflow/activities/sheets/MiniappDestroyConfirmJob.tsx @@ -55,6 +55,21 @@ function MiniappDestroyConfirmJobContent() { const walletName = targetWallet?.name || t('common:unknownWallet'); const lockDescription = `${appName || t('common:unknownDApp')} ${t('common:requestsDestroy')}`; + const parsedAmount = useMemo(() => { + if (!chainConfig) return null; + try { + return Amount.fromRaw(amount, chainConfig.decimals, asset); + } catch { + return null; + } + }, [amount, asset, chainConfig]); + + const displayAmount = useMemo(() => parsedAmount?.toFormatted({ trimTrailingZeros: false }) ?? amount, [amount, parsedAmount]); + const displayDecimals = chainConfig?.decimals ?? 8; + const amountInvalidMessage = useMemo(() => { + if (!chainConfig) return null; + return parsedAmount ? null : t('transaction:broadcast.invalidParams'); + }, [chainConfig, parsedAmount, t]); const handleConfirm = useCallback(() => { if (isConfirming) return; @@ -77,7 +92,11 @@ function MiniappDestroyConfirmJobContent() { } // 执行销毁 - const amountObj = Amount.fromFormatted(amount, chainConfig.decimals, asset); + if (!parsedAmount) { + throw new Error('Invalid miniapp destroy amount'); + } + + const amountObj = parsedAmount; const result = await submitBioforestBurn({ chainConfig, @@ -125,7 +144,7 @@ function MiniappDestroyConfirmJobContent() { walletAddress: from, walletChainId: resolvedChainId, }); - }, [isConfirming, targetWallet, chainConfig, asset, from, amount, pop, push, t, lockDescription, appName, appIcon, walletName, resolvedChainId]); + }, [isConfirming, targetWallet, chainConfig, asset, from, amount, parsedAmount, pop, push, t, lockDescription, appName, appIcon, walletName, resolvedChainId]); const handleCancel = useCallback(() => { const event = new CustomEvent('miniapp-destroy-confirm', { @@ -160,7 +179,7 @@ function MiniappDestroyConfirmJobContent() {
{/* Amount */}
- +
{/* From address */} @@ -171,6 +190,12 @@ function MiniappDestroyConfirmJobContent() {
+ {amountInvalidMessage && ( +
+ {amountInvalidMessage} +
+ )} + {/* Chain & Asset */}
{t('common:network')} @@ -195,7 +220,7 @@ function MiniappDestroyConfirmJobContent() {