diff --git "a/docs/white-book/03-\346\236\266\346\236\204\347\257\207/08-Amount\347\261\273\345\236\213\347\263\273\347\273\237/index.md" "b/docs/white-book/03-\346\236\266\346\236\204\347\257\207/08-Amount\347\261\273\345\236\213\347\263\273\347\273\237/index.md" new file mode 100644 index 000000000..e1fabcc09 --- /dev/null +++ "b/docs/white-book/03-\346\236\266\346\236\204\347\257\207/08-Amount\347\261\273\345\236\213\347\263\273\347\273\237/index.md" @@ -0,0 +1,430 @@ +# Amount 类型系统 + +> 类型安全的货币金额处理 + +--- + +## 概述 + +`Amount` 类是 KeyApp 中处理所有货币金额的核心类型。它提供了类型安全的金额操作,避免了常见的精度问题和单位混淆。 + +### 为什么需要 Amount 类型? + +在加密货币应用中,金额处理面临以下挑战: + +1. **精度问题**: JavaScript 的 `number` 类型无法精确表示大数值 +2. **单位混淆**: 混淆原始单位(如 wei)和格式化单位(如 ETH) +3. **类型不安全**: 字符串表示的金额容易出错 +4. **缺乏元数据**: 金额需要知道其 decimals 和符号才能正确显示 + +`Amount` 类通过封装这些复杂性,提供了统一、安全的金额处理方式。 + +--- + +## 核心设计 + +### 不可变性 + +`Amount` 实例是不可变的。所有算术操作返回新的 `Amount` 实例: + +```typescript +const a = Amount.fromFormatted('1', 18, 'ETH') +const b = Amount.fromFormatted('0.5', 18, 'ETH') +const c = a.add(b) // 返回新实例,a 和 b 不变 +``` + +### 元数据 + +每个 `Amount` 实例携带完整的元数据: + +```typescript +interface AmountData { + _value: Big // 精确的数值(使用 Big.js) + _decimals: number // 小数位数 + _symbol?: string // 货币符号 +} +``` + +--- + +## API 参考 + +### 工厂方法 + +#### `Amount.fromRaw(raw: string | bigint, decimals: number, symbol?: string)` + +从原始值(最小单位)创建 Amount。 + +```typescript +// 1 ETH = 10^18 wei +const eth = Amount.fromRaw('1000000000000000000', 18, 'ETH') +eth.toFormatted() // "1" + +// 100 USDT = 10^8 最小单位 +const usdt = Amount.fromRaw('10000000000', 8, 'USDT') +usdt.toFormatted() // "100" +``` + +#### `Amount.fromFormatted(formatted: string | number, decimals: number, symbol?: string)` + +从格式化值(用户可读)创建 Amount。 + +```typescript +const eth = Amount.fromFormatted('1.5', 18, 'ETH') +eth.toRawString() // "1500000000000000000" + +const usdt = Amount.fromFormatted(100, 8, 'USDT') +usdt.toRawString() // "10000000000" +``` + +#### `Amount.zero(decimals: number, symbol?: string)` + +创建零值 Amount。 + +```typescript +const zero = Amount.zero(18, 'ETH') +zero.isZero() // true +``` + +### 转换方法 + +#### `toRawString(): string` + +返回原始值的字符串表示(无小数点)。 + +```typescript +Amount.fromFormatted('1.5', 18, 'ETH').toRawString() +// "1500000000000000000" +``` + +#### `toRawBigInt(): bigint` + +返回原始值的 bigint 表示。 + +```typescript +Amount.fromFormatted('1', 18, 'ETH').toRawBigInt() +// 1000000000000000000n +``` + +#### `toFormatted(precision?: number): string` + +返回格式化的字符串表示。 + +```typescript +const eth = Amount.fromRaw('1234567890000000000', 18, 'ETH') +eth.toFormatted() // "1.23456789" +eth.toFormatted(2) // "1.23" +eth.toFormatted(8) // "1.23456789" +``` + +#### `toNumber(): number` + +返回 JavaScript number(可能有精度损失)。 + +```typescript +Amount.fromFormatted('1.5', 18, 'ETH').toNumber() // 1.5 +``` + +### 算术操作 + +所有算术操作返回新的 `Amount` 实例。 + +#### `add(other: Amount): Amount` + +加法。 + +```typescript +const a = Amount.fromFormatted('1', 18, 'ETH') +const b = Amount.fromFormatted('0.5', 18, 'ETH') +a.add(b).toFormatted() // "1.5" +``` + +#### `subtract(other: Amount): Amount` + +减法。 + +```typescript +const a = Amount.fromFormatted('1', 18, 'ETH') +const b = Amount.fromFormatted('0.3', 18, 'ETH') +a.subtract(b).toFormatted() // "0.7" +``` + +#### `multiply(factor: number | string): Amount` + +乘法(用于汇率转换等)。 + +```typescript +const eth = Amount.fromFormatted('2', 18, 'ETH') +eth.multiply(1.5).toFormatted() // "3" +``` + +#### `divide(divisor: number | string): Amount` + +除法。 + +```typescript +const eth = Amount.fromFormatted('10', 18, 'ETH') +eth.divide(4).toFormatted() // "2.5" +``` + +### 比较操作 + +#### `isZero(): boolean` + +判断是否为零。 + +#### `isPositive(): boolean` + +判断是否为正数。 + +#### `isNegative(): boolean` + +判断是否为负数。 + +#### `equals(other: Amount): boolean` + +判断是否相等。 + +#### `greaterThan(other: Amount): boolean` + +判断是否大于。 + +#### `lessThan(other: Amount): boolean` + +判断是否小于。 + +#### `greaterThanOrEqual(other: Amount): boolean` + +判断是否大于等于。 + +#### `lessThanOrEqual(other: Amount): boolean` + +判断是否小于等于。 + +### 属性访问 + +```typescript +const eth = Amount.fromFormatted('1.5', 18, 'ETH') +eth.decimals // 18 +eth.symbol // "ETH" +``` + +--- + +## 序列化 + +### JSON 序列化 + +`Amount` 实现了 `toJSON()` 方法,返回可序列化的对象: + +```typescript +const eth = Amount.fromFormatted('1.5', 18, 'ETH') +JSON.stringify(eth) +// {"raw":"1500000000000000000","decimals":18,"symbol":"ETH"} +``` + +### 从 JSON 恢复 + +```typescript +const json = { raw: '1500000000000000000', decimals: 18, symbol: 'ETH' } +const eth = Amount.fromRaw(json.raw, json.decimals, json.symbol) +``` + +### Zod Schema + +用于验证 API 响应: + +```typescript +import { z } from 'zod' +import { Amount } from '@/types/amount' + +export const AmountSchema = z.object({ + raw: z.string(), + decimals: z.number(), + symbol: z.string().optional(), +}).transform(data => Amount.fromRaw(data.raw, data.decimals, data.symbol)) +``` + +--- + +## 使用场景 + +### 资产余额 + +```typescript +interface AssetInfo { + assetType: string + name: string + amount: Amount // 使用 Amount 类型 + decimals: number + logo?: string +} + +// 使用 +const asset: AssetInfo = { + assetType: 'ETH', + name: 'Ethereum', + amount: Amount.fromRaw('1500000000000000000', 18, 'ETH'), + decimals: 18, +} + +// 显示 +console.log(`余额: ${asset.amount.toFormatted()} ${asset.assetType}`) +``` + +### 交易记录 + +```typescript +interface TransactionRecord { + id: string + type: 'send' | 'receive' + amount: Amount // 交易金额 + fee?: Amount // 手续费 + // ... +} +``` + +### 发送交易 + +```typescript +// 用户输入 +const userInput = '1.5' +const amount = Amount.fromFormatted(userInput, 18, 'ETH') + +// 验证余额 +if (amount.greaterThan(balance.amount)) { + throw new Error('余额不足') +} + +// 构建交易(使用原始值) +const rawAmount = amount.toRawBigInt() +``` + +### 费用显示 + +```typescript +const fee = Amount.fromRaw('2000000000000000', 18, 'ETH') +console.log(`手续费: ${fee.toFormatted(6)} ETH`) // "手续费: 0.002 ETH" +``` + +--- + +## 最佳实践 + +### 1. 始终使用工厂方法 + +```typescript +// ✅ 正确 +const amount = Amount.fromFormatted('1.5', 18, 'ETH') + +// ❌ 错误 - 不要直接构造 +const amount = new Amount(...) // Amount 构造函数是私有的 +``` + +### 2. 保持 decimals 一致 + +在同一上下文中操作的 Amount 应该使用相同的 decimals: + +```typescript +// ✅ 正确 +const a = Amount.fromFormatted('1', 18, 'ETH') +const b = Amount.fromFormatted('0.5', 18, 'ETH') +const c = a.add(b) + +// ❌ 危险 - decimals 不匹配 +const a = Amount.fromFormatted('1', 18, 'ETH') +const b = Amount.fromFormatted('100', 6, 'USDT') // 不同 decimals +const c = a.add(b) // 可能导致错误 +``` + +### 3. 使用 toNumber() 谨慎 + +`toNumber()` 可能有精度损失,仅用于显示目的: + +```typescript +// ✅ 用于显示 + + +// ❌ 用于计算 +const result = amount.toNumber() * 2 // 可能丢失精度 +// ✅ 应该使用 +const result = amount.multiply(2) +``` + +### 4. API 边界转换 + +在 API 边界进行 Amount 和原始值的转换: + +```typescript +// 接收 API 响应 +const apiResponse = await fetch('/api/balance') +const data = await apiResponse.json() +const balance = Amount.fromRaw(data.balance, data.decimals, data.symbol) + +// 发送 API 请求 +const amount = Amount.fromFormatted(userInput, 18, 'ETH') +await fetch('/api/transfer', { + body: JSON.stringify({ + amount: amount.toRawString(), + // ... + }) +}) +``` + +--- + +## 迁移指南 + +### 从 string 迁移 + +```typescript +// 旧代码 +interface OldAssetInfo { + amount: string // "1.5" +} + +// 新代码 +interface NewAssetInfo { + amount: Amount +} + +// 迁移 +const oldAsset: OldAssetInfo = { amount: '1.5' } +const newAsset: NewAssetInfo = { + amount: Amount.fromFormatted(oldAsset.amount, 18, 'ETH') +} +``` + +### 从 bigint 迁移 + +```typescript +// 旧代码 +interface OldBalance { + raw: bigint // 1500000000000000000n + formatted: string + decimals: number +} + +// 新代码 +interface NewBalance { + amount: Amount +} + +// 迁移 +const oldBalance: OldBalance = { + raw: 1500000000000000000n, + formatted: '1.5', + decimals: 18 +} +const newBalance: NewBalance = { + amount: Amount.fromRaw(oldBalance.raw.toString(), oldBalance.decimals) +} +``` + +--- + +## 相关文档 + +- [AmountDisplay 组件](../../05-组件篇/02-通用组件/AmountDisplay.md) +- [ITransactionService](../../04-服务篇/02-链服务/ITransactionService.md) +- [IAssetService](../../04-服务篇/02-链服务/IAssetService.md) diff --git "a/docs/white-book/03-\346\236\266\346\236\204\347\257\207/index.md" "b/docs/white-book/03-\346\236\266\346\236\204\347\257\207/index.md" index b0f0b5067..177afea94 100644 --- "a/docs/white-book/03-\346\236\266\346\236\204\347\257\207/index.md" +++ "b/docs/white-book/03-\346\236\266\346\236\204\347\257\207/index.md" @@ -53,6 +53,16 @@ - 服务端状态:TanStack Query - 状态持久化策略 +### [第十四章:Amount 类型系统](./08-Amount类型系统/) + +定义类型安全的货币金额处理方案。 + +**要点**: +- Amount 不可变类型,携带 decimals 和 symbol 元数据 +- 工厂方法:fromRaw()、fromFormatted()、zero() +- 精确算术操作,避免 JavaScript 浮点数精度问题 +- 序列化/反序列化支持 + --- ## 本篇来源 diff --git "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/ITransactionService.md" "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/ITransactionService.md" index a6fcd5011..0678badd2 100644 --- "a/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/ITransactionService.md" +++ "b/docs/white-book/04-\346\234\215\345\212\241\347\257\207/02-\351\223\276\346\234\215\345\212\241/ITransactionService.md" @@ -46,11 +46,11 @@ ITransactionService { ### TransferParams -``` +```typescript TransferParams { from: Address // 发送方地址 to: Address // 接收方地址 - amount: bigint // 发送金额(最小单位) + amount: Amount // 发送金额(使用 Amount 类型) tokenAddress?: Address // 代币地址(原生代币为空) memo?: string // 备注(部分链支持) @@ -62,9 +62,11 @@ TransferParams { } ``` +> 关于 Amount 类型的详细信息,请参阅 [Amount 类型系统](../../03-架构篇/08-Amount类型系统/index.md)。 + ### FeeEstimate -``` +```typescript FeeEstimate { slow: Fee // 慢速(~5分钟) standard: Fee // 标准(~1分钟) @@ -72,11 +74,10 @@ FeeEstimate { } Fee { - amount: bigint // 费用金额 - formatted: string // 格式化显示 + amount: Amount // 费用金额(使用 Amount 类型) estimatedTime: number // 预估确认时间(秒) gasLimit: bigint - gasPrice: bigint + gasPrice: Amount } ``` @@ -93,13 +94,13 @@ TransactionStatus { ### Transaction -``` +```typescript Transaction { hash: TransactionHash from: Address to: Address - amount: bigint - fee: bigint + amount: Amount // 交易金额(使用 Amount 类型) + fee: Amount // 手续费(使用 Amount 类型) status: TransactionStatus timestamp: number blockNumber?: bigint diff --git "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/AmountDisplay.md" "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/AmountDisplay.md" index 66b515de4..7f1c0a54d 100644 --- "a/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/AmountDisplay.md" +++ "b/docs/white-book/05-\347\273\204\344\273\266\347\257\207/02-\351\200\232\347\224\250\347\273\204\344\273\266/AmountDisplay.md" @@ -10,6 +10,7 @@ ### 核心特性 +- **Amount 类型支持**: 直接接收 `Amount` 对象,自动提取元数据 - **数字动画**: 数值变化时逐位平滑过渡动画 - **加载状态**: 显示 "0" 配合呼吸动画,数据到达后平滑过渡到真实值 - **完整可访问性**: 屏幕阅读器正确读取数值 @@ -17,13 +18,35 @@ --- +## 与 Amount 类型集成 + +推荐使用 `Amount` 对象作为 value 属性,组件会自动提取 decimals 和 symbol: + +```tsx +import { Amount } from '@/types/amount' +import { AmountDisplay } from '@/components/amount-display' + +const balance = Amount.fromRaw('1500000000000000000', 18, 'ETH') + +// 使用 Amount 对象(推荐) + +// 自动显示: 1.5 ETH + +// 也支持直接使用 number + +``` + +更多关于 Amount 类型的信息,请参阅 [Amount 类型系统](../../03-架构篇/08-Amount类型系统/index.md)。 + +--- + ## 属性规范 | 属性 | 类型 | 必需 | 默认值 | 说明 | |-----|------|-----|-------|------| -| value | number \| bigint \| string | Y | - | 金额值 | -| symbol | string | N | - | 货币符号 | -| decimals | number | N | 8 | 小数位数 | +| value | number \| bigint \| string \| Amount | Y | - | 金额值(推荐使用 Amount) | +| symbol | string | N | - | 货币符号(使用 Amount 时自动提取) | +| decimals | number | N | 8 | 小数位数(使用 Amount 时自动提取) | | sign | enum | N | 'never' | 符号显示模式 | | color | enum | N | 'default' | 颜色模式 | | size | enum | N | 'md' | 尺寸 | diff --git a/e2e/__screenshots__/Mobile-Chrome/send-form-filled.png b/e2e/__screenshots__/Mobile-Chrome/send-form-filled.png new file mode 100644 index 000000000..869a005f8 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/send-form-filled.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-initial.png b/e2e/__screenshots__/Mobile-Chrome/send-initial.png new file mode 100644 index 000000000..353ef7da1 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/send-initial.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-insufficient-balance.png b/e2e/__screenshots__/Mobile-Chrome/send-insufficient-balance.png new file mode 100644 index 000000000..b99191208 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/send-insufficient-balance.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-page-filled.png b/e2e/__screenshots__/Mobile-Chrome/send-page-filled.png new file mode 100644 index 000000000..7d1039e24 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/send-page-filled.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-page-initial.png b/e2e/__screenshots__/Mobile-Chrome/send-page-initial.png new file mode 100644 index 000000000..353ef7da1 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/send-page-initial.png differ diff --git a/e2e/__screenshots__/Mobile-Chrome/send-page-insufficient.png b/e2e/__screenshots__/Mobile-Chrome/send-page-insufficient.png new file mode 100644 index 000000000..ed44c2bd4 Binary files /dev/null and b/e2e/__screenshots__/Mobile-Chrome/send-page-insufficient.png differ diff --git a/e2e/send-transaction.spec.ts b/e2e/send-transaction.spec.ts new file mode 100644 index 000000000..2bb9c2a0d --- /dev/null +++ b/e2e/send-transaction.spec.ts @@ -0,0 +1,270 @@ +import { test, expect, type Page } from '@playwright/test' + +/** + * 发送交易 E2E 测试 + * + * 测试发送交易流程,特别是 Amount 类型相关的功能: + * - 金额输入和格式化 + * - 余额验证 + * - 手续费显示 + * - 确认流程 + */ + +// 测试钱包数据(带余额) +const TEST_WALLET_DATA = { + wallets: [ + { + id: 'test-wallet-1', + name: '测试钱包', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + chain: 'ethereum', + chainAddresses: [ + { + chain: 'ethereum', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + tokens: [ + { symbol: 'ETH', balance: '1.5', decimals: 18 }, + { symbol: 'USDT', balance: '1000', decimals: 6, contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7' }, + ], + }, + { + chain: 'bfmeta', + address: 'c7R6wVdPvHqvRxe5Q9ZvWr7CpPn5Mk5Xz3', + tokens: [ + { symbol: 'BFM', balance: '10000', decimals: 8 }, + ], + }, + ], + encryptedMnemonic: { ciphertext: 'test', iv: 'test', salt: 'test' }, + createdAt: Date.now(), + tokens: [], + }, + ], + currentWalletId: 'test-wallet-1', + selectedChain: 'ethereum', +} + +// 辅助函数:设置测试钱包 +async function setupTestWallet(page: Page, targetUrl: string = '/', language: string = 'en') { + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data.wallet)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: data.lang, currency: 'USD' })) + }, { wallet: TEST_WALLET_DATA, lang: language }) + + const hashUrl = targetUrl === '/' ? '/' : `/#${targetUrl}` + await page.goto(hashUrl) + await page.waitForLoadState('networkidle') +} + +async function waitForAppReady(page: Page) { + await page.locator('svg[aria-label="加载中"]').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}) +} + +// 获取发送页面的输入元素 +async function getSendPageInputs(page: Page) { + // 等待页面加载 + await page.waitForSelector('h1', { timeout: 10000 }) + + // 地址输入框通常是第一个文本输入框 + const addressInput = page.locator('input').first() + // 金额输入框通常是第二个或有 placeholder="0" 的 + const amountInput = page.locator('input[placeholder="0"], input').nth(1) + + return { addressInput, amountInput } +} + +test.describe('发送交易 - 金额输入测试', () => { + test('输入有效金额', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { amountInput } = await getSendPageInputs(page) + + // 输入有效金额 + await amountInput.fill('0.5') + + // 验证输入值 + const value = await amountInput.inputValue() + expect(value).toBe('0.5') + }) + + test('输入超过余额的金额触发错误', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { addressInput, amountInput } = await getSendPageInputs(page) + + // 填写有效地址 + await addressInput.fill('0x1234567890abcdef1234567890abcdef12345678') + + // 输入超过余额的金额(余额是 1.5 ETH) + await amountInput.fill('100') + + // 等待验证 + await page.waitForTimeout(300) + + // 验证继续按钮被禁用 + const continueBtn = page.locator('button:has-text("Continue"), button:has-text("继续"), [data-testid="send-continue-button"]') + await expect(continueBtn.first()).toBeDisabled() + }) + + test('清空金额后继续按钮禁用', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { addressInput, amountInput } = await getSendPageInputs(page) + + // 填写有效数据 + await addressInput.fill('0x1234567890abcdef1234567890abcdef12345678') + await amountInput.fill('0.1') + + // 等待 + await page.waitForTimeout(300) + + // 清空金额 + await amountInput.clear() + + // 继续按钮应该禁用 + const continueBtn = page.locator('button:has-text("Continue"), button:has-text("继续"), [data-testid="send-continue-button"]') + await expect(continueBtn.first()).toBeDisabled() + }) + + test('处理零金额', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { addressInput, amountInput } = await getSendPageInputs(page) + + await addressInput.fill('0x1234567890abcdef1234567890abcdef12345678') + await amountInput.fill('0') + + await page.waitForTimeout(300) + + // 零金额应该禁用继续按钮 + const continueBtn = page.locator('button:has-text("Continue"), button:has-text("继续"), [data-testid="send-continue-button"]') + await expect(continueBtn.first()).toBeDisabled() + }) +}) + +test.describe('发送交易 - 确认流程测试', () => { + test('填写表单后按钮状态变化', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { addressInput, amountInput } = await getSendPageInputs(page) + + // 验证初始状态按钮禁用 + const continueBtn = page.locator('button:has-text("Continue"), button:has-text("继续"), [data-testid="send-continue-button"]') + await expect(continueBtn.first()).toBeDisabled() + + // 只填写地址 + await addressInput.fill('0x1234567890abcdef1234567890abcdef12345678') + await page.waitForTimeout(200) + + // 仍然禁用(没有金额) + await expect(continueBtn.first()).toBeDisabled() + + // 填写金额 + await amountInput.fill('0.1') + + // 等待状态更新(费用估算等) + await page.waitForTimeout(1000) + + // 记录按钮最终状态(依赖 mock 服务配置) + const isEnabled = await continueBtn.first().isEnabled() + console.log(`Continue button enabled after form fill: ${isEnabled}`) + }) + + // 此测试需要 mock 服务正确配置,在某些环境可能跳过 + test('点击继续按钮交互', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { addressInput, amountInput } = await getSendPageInputs(page) + + await addressInput.fill('0x1234567890abcdef1234567890abcdef12345678') + await amountInput.fill('0.1') + + await page.waitForTimeout(1000) + + const continueBtn = page.locator('[data-testid="send-continue-button"]') + + // 检查按钮是否启用 + const isEnabled = await continueBtn.isEnabled() + + if (isEnabled) { + // 如果启用,尝试点击 + await continueBtn.click() + await page.waitForTimeout(500) + + // 检查是否有确认相关内容出现 + const pageContent = await page.content() + const hasConfirmContent = pageContent.includes('确认') || pageContent.includes('Confirm') || pageContent.includes('0.1') + console.log(`Confirm content visible: ${hasConfirmContent}`) + } else { + console.log('Continue button not enabled - mock service may not be configured correctly') + } + }) +}) + +test.describe('发送交易 - 截图测试', () => { + test('发送页面初始状态', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + await page.waitForSelector('h1', { timeout: 10000 }) + + await expect(page).toHaveScreenshot('send-page-initial.png') + }) + + test('发送页面填写表单后', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { addressInput, amountInput } = await getSendPageInputs(page) + + await addressInput.fill('0x1234567890abcdef1234567890abcdef12345678') + await amountInput.fill('0.5') + + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('send-page-filled.png') + }) + + test('发送页面余额不足', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + const { addressInput, amountInput } = await getSendPageInputs(page) + + await addressInput.fill('0x1234567890abcdef1234567890abcdef12345678') + await amountInput.fill('999999') + + await page.waitForTimeout(300) + + await expect(page).toHaveScreenshot('send-page-insufficient.png') + }) +}) + +test.describe('发送交易 - 金额格式化显示', () => { + test('页面显示格式化余额', async ({ page }) => { + await setupTestWallet(page, '/send') + await waitForAppReady(page) + + await page.waitForSelector('h1', { timeout: 10000 }) + + // 验证页面上显示了格式化的余额(如 "1.5" 而不是 "1500000000000000000") + // 余额应该小于 100(原始 wei 值会非常大) + const balanceText = await page.locator('text=/\\d+\\.?\\d*\\s*(ETH|Available|余额)/').first().textContent() + + if (balanceText) { + // 提取数字 + const match = balanceText.match(/(\d+\.?\d*)/) + if (match) { + const balanceValue = parseFloat(match[1]) + // 格式化后的余额应该是合理的数值(不是 wei) + expect(balanceValue).toBeLessThan(1000000) + } + } + }) +}) diff --git a/e2e/transaction-history.spec.ts b/e2e/transaction-history.spec.ts new file mode 100644 index 000000000..ad5b54db8 --- /dev/null +++ b/e2e/transaction-history.spec.ts @@ -0,0 +1,244 @@ +import { test, expect, type Page } from '@playwright/test' + +/** + * 交易历史 E2E 测试 + * + * 测试交易历史页面,特别是 Amount 类型相关的功能: + * - 交易金额显示格式化 + * - 手续费显示 + * - 交易详情页面 + */ + +// 测试钱包数据(带交易历史) +const TEST_WALLET_DATA = { + wallets: [ + { + id: 'test-wallet-1', + name: '测试钱包', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + chain: 'ethereum', + chainAddresses: [ + { + chain: 'ethereum', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + tokens: [ + { symbol: 'ETH', balance: '1.5', decimals: 18 }, + ], + }, + ], + encryptedMnemonic: { ciphertext: 'test', iv: 'test', salt: 'test' }, + createdAt: Date.now(), + tokens: [], + }, + ], + currentWalletId: 'test-wallet-1', + selectedChain: 'ethereum', +} + +// 辅助函数:设置测试钱包 +async function setupTestWallet(page: Page, targetUrl: string = '/', language: string = 'en') { + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data.wallet)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: data.lang, currency: 'USD' })) + }, { wallet: TEST_WALLET_DATA, lang: language }) + + const hashUrl = targetUrl === '/' ? '/' : `/#${targetUrl}` + await page.goto(hashUrl) + await page.waitForLoadState('networkidle') +} + +async function waitForAppReady(page: Page) { + await page.locator('svg[aria-label="加载中"]').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}) +} + +test.describe('交易历史 - 金额显示测试', () => { + test('交易列表显示格式化金额', async ({ page }) => { + await setupTestWallet(page, '/history') + await waitForAppReady(page) + + // 等待页面加载 + await page.waitForTimeout(500) + + // 如果有交易记录,验证金额格式 + const transactionItems = page.locator('[data-testid="transaction-item"], [role="button"]:has(.font-mono)') + const count = await transactionItems.count() + + if (count > 0) { + // 验证金额以格式化形式显示(带小数点和符号) + const firstItem = transactionItems.first() + await expect(firstItem.locator('text=/\\d+\\.?\\d*/')).toBeVisible() + } + }) + + test('交易历史页面截图 - 有交易', async ({ page }) => { + await setupTestWallet(page, '/history') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('history-with-transactions.png') + }) + + test('交易历史过滤器工作正常', async ({ page }) => { + await setupTestWallet(page, '/history') + await waitForAppReady(page) + + // 查找过滤器选择器 + const chainFilter = page.locator('[aria-label*="链"], select[name="chain"], [data-testid="chain-filter"]') + const periodFilter = page.locator('[aria-label*="时间"], select[name="period"], [data-testid="period-filter"]') + + // 如果过滤器存在,测试切换 + if (await chainFilter.isVisible()) { + await chainFilter.selectOption({ index: 0 }) + await page.waitForTimeout(300) + } + + if (await periodFilter.isVisible()) { + await periodFilter.selectOption({ index: 0 }) + await page.waitForTimeout(300) + } + }) +}) + +test.describe('交易详情 - 金额显示测试', () => { + test('交易详情页面显示完整信息', async ({ page }) => { + // 直接访问交易详情页面(使用 mock 交易 ID) + await setupTestWallet(page, '/transaction/tx-1') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + // 验证页面标题 + const pageTitle = page.locator('[data-testid="page-title"], h1') + await expect(pageTitle).toBeVisible() + }) + + test('交易详情显示金额和手续费', async ({ page }) => { + await setupTestWallet(page, '/transaction/tx-1') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + // 如果交易存在,验证金额和手续费显示 + const amountDisplay = page.locator('[data-testid="transaction-amount"], .text-2xl, .text-xl') + if (await amountDisplay.first().isVisible()) { + // 验证金额格式化显示 + await expect(amountDisplay.first()).toContainText(/\d/) + } + }) + + test('交易详情页面截图', async ({ page }) => { + await setupTestWallet(page, '/transaction/tx-1') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('transaction-detail.png') + }) +}) + +test.describe('交易历史 - 交互测试', () => { + test('点击交易项打开详情', async ({ page }) => { + await setupTestWallet(page, '/history') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + // 查找交易项 + const transactionItems = page.locator('[data-testid="transaction-item"], [role="button"]:has(.font-mono)') + const count = await transactionItems.count() + + if (count > 0) { + // 点击第一个交易 + await transactionItems.first().click() + + // 验证导航到详情页面 + await page.waitForURL(/\/#\/transaction\//) + } + }) + + test('返回按钮功能正常', async ({ page }) => { + await setupTestWallet(page, '/history') + await waitForAppReady(page) + + // 点击返回按钮 + const backButton = page.locator('[aria-label*="返回"], [aria-label*="back"], button:has(svg)') + if (await backButton.first().isVisible()) { + await backButton.first().click() + + // 验证返回到首页或上一页 + await page.waitForTimeout(300) + } + }) +}) + +test.describe('交易历史 - 空状态测试', () => { + test('无交易时显示空状态', async ({ page }) => { + // 使用一个新钱包(无交易历史) + const emptyWalletData = { + ...TEST_WALLET_DATA, + wallets: [{ + ...TEST_WALLET_DATA.wallets[0], + id: 'empty-wallet', + }], + currentWalletId: 'empty-wallet', + } + + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: 'en', currency: 'USD' })) + }, emptyWalletData) + + await page.goto('/#/history') + await page.waitForLoadState('networkidle') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + // 验证显示空状态消息 + const emptyState = page.locator('text=/no.*transaction|暂无|empty/i') + // 空状态可能是预期的 + }) + + test('空状态页面截图', async ({ page }) => { + const emptyWalletData = { + ...TEST_WALLET_DATA, + wallets: [{ + ...TEST_WALLET_DATA.wallets[0], + id: 'empty-wallet-2', + }], + currentWalletId: 'empty-wallet-2', + } + + await page.addInitScript((data) => { + localStorage.setItem('bfm_wallets', JSON.stringify(data)) + localStorage.setItem('bfm_preferences', JSON.stringify({ language: 'en', currency: 'USD' })) + }, emptyWalletData) + + await page.goto('/#/history') + await page.waitForLoadState('networkidle') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + await expect(page).toHaveScreenshot('history-empty-state.png') + }) +}) + +test.describe('交易历史 - 刷新功能测试', () => { + test('下拉刷新或刷新按钮', async ({ page }) => { + await setupTestWallet(page, '/history') + await waitForAppReady(page) + + await page.waitForTimeout(500) + + // 查找刷新按钮 + const refreshButton = page.locator('[aria-label*="刷新"], [aria-label*="refresh"], button:has-text("刷新")') + if (await refreshButton.isVisible()) { + await refreshButton.click() + + // 等待刷新完成 + await page.waitForTimeout(1000) + } + }) +}) diff --git a/src/components/asset/asset-item.stories.tsx b/src/components/asset/asset-item.stories.tsx index 528b30193..8d3184c99 100644 --- a/src/components/asset/asset-item.stories.tsx +++ b/src/components/asset/asset-item.stories.tsx @@ -2,11 +2,12 @@ import type { Meta, StoryObj } from '@storybook/react' import { fn } from '@storybook/test' import { AssetItem } from './asset-item' import type { AssetInfo } from '@/types/asset' +import { Amount } from '@/types/amount' const mockAsset: AssetInfo = { assetType: 'ETH', name: 'Ethereum', - amount: '1500000000000000000', // 1.5 ETH + amount: Amount.fromRaw('1500000000000000000', 18, 'ETH'), // 1.5 ETH decimals: 18, } @@ -80,7 +81,7 @@ export const USDT: Story = { asset: { assetType: 'USDT', name: 'Tether USD', - amount: '100000000', // 100 USDT + amount: Amount.fromRaw('100000000', 6, 'USDT'), // 100 USDT decimals: 6, contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', priceUsd: 1, @@ -94,7 +95,7 @@ export const Bitcoin: Story = { asset: { assetType: 'BTC', name: 'Bitcoin', - amount: '50000000', // 0.5 BTC + amount: Amount.fromRaw('50000000', 8, 'BTC'), // 0.5 BTC decimals: 8, priceUsd: 45000, priceChange24h: -1.5, @@ -112,7 +113,7 @@ export const SmallAmount: Story = { args: { asset: { ...mockAsset, - amount: '1000000000000000', // 0.001 ETH + amount: Amount.fromRaw('1000000000000000', 18, 'ETH'), // 0.001 ETH priceUsd: 2500, priceChange24h: 2.3, }, diff --git a/src/components/asset/asset-item.test.tsx b/src/components/asset/asset-item.test.tsx index acadabe34..10473cf09 100644 --- a/src/components/asset/asset-item.test.tsx +++ b/src/components/asset/asset-item.test.tsx @@ -2,12 +2,13 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { AssetItem } from './asset-item' import type { AssetInfo } from '@/types/asset' +import { Amount } from '@/types/amount' describe('AssetItem', () => { const mockAsset: AssetInfo = { assetType: 'ETH', name: 'Ethereum', - amount: '1500000000000000000', // 1.5 ETH + amount: Amount.fromRaw('1500000000000000000', 18, 'ETH'), // 1.5 ETH decimals: 18, logoUrl: undefined, } @@ -26,7 +27,7 @@ describe('AssetItem', () => { it('uses assetType as name when name is not provided', () => { const assetWithoutName: AssetInfo = { assetType: 'USDT', - amount: '100000000', + amount: Amount.fromRaw('100000000', 6, 'USDT'), decimals: 6, } render() @@ -76,7 +77,7 @@ describe('AssetItem', () => { const assetWithPrice: AssetInfo = { assetType: 'ETH', name: 'Ethereum', - amount: '1000000000000000000', // 1 ETH + amount: Amount.fromRaw('1000000000000000000', 18, 'ETH'), // 1 ETH decimals: 18, priceUsd: 2500, priceChange24h: 2.3, diff --git a/src/components/asset/asset-item.tsx b/src/components/asset/asset-item.tsx index a073ac529..a8be30796 100644 --- a/src/components/asset/asset-item.tsx +++ b/src/components/asset/asset-item.tsx @@ -1,6 +1,6 @@ import { cn } from '@/lib/utils'; import { TokenIcon } from '@/components/token/token-icon'; -import { formatAssetAmount, formatFiatValue, formatPriceChange, type AssetInfo } from '@/types/asset'; +import { formatFiatValue, formatPriceChange, type AssetInfo } from '@/types/asset'; import { IconChevronRight as ChevronRight } from '@tabler/icons-react'; export interface AssetItemProps { @@ -30,11 +30,10 @@ export function AssetItem({ exchangeRate, className, }: AssetItemProps) { - const formattedAmount = formatAssetAmount(asset.amount, asset.decimals); + const formattedAmount = asset.amount.toFormatted(); const displayName = asset.name || asset.assetType; // Calculate fiat value if price is available - // Use exchange rate for conversion when currency is not USD const fiatValue = asset.priceUsd !== undefined ? formatFiatValue(asset.amount, asset.decimals, asset.priceUsd, { diff --git a/src/components/asset/asset-list.stories.tsx b/src/components/asset/asset-list.stories.tsx index 017b823a1..737ec7d27 100644 --- a/src/components/asset/asset-list.stories.tsx +++ b/src/components/asset/asset-list.stories.tsx @@ -2,31 +2,32 @@ import type { Meta, StoryObj } from '@storybook/react' import { fn } from '@storybook/test' import { AssetList } from './asset-list' import type { AssetInfo } from '@/types/asset' +import { Amount } from '@/types/amount' const mockAssets: AssetInfo[] = [ { assetType: 'ETH', name: 'Ethereum', - amount: '1500000000000000000', + amount: Amount.fromRaw('1500000000000000000', 18, 'ETH'), decimals: 18, }, { assetType: 'USDT', name: 'Tether USD', - amount: '100000000', + amount: Amount.fromRaw('100000000', 6, 'USDT'), decimals: 6, contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', }, { assetType: 'USDC', name: 'USD Coin', - amount: '50000000', + amount: Amount.fromRaw('50000000', 6, 'USDC'), decimals: 6, }, { assetType: 'BTC', name: 'Bitcoin', - amount: '50000000', + amount: Amount.fromRaw('50000000', 8, 'BTC'), decimals: 8, }, ] @@ -81,13 +82,13 @@ export const ManyAssets: Story = { { assetType: 'TRX', name: 'TRON', - amount: '10000000000', + amount: Amount.fromRaw('10000000000', 6, 'TRX'), decimals: 6, }, { assetType: 'BFM', name: 'BioForest Meta', - amount: '500000000000', + amount: Amount.fromRaw('500000000000', 8, 'BFM'), decimals: 8, }, ], diff --git a/src/components/asset/asset-list.test.tsx b/src/components/asset/asset-list.test.tsx index 7c0cf222b..6b7ef40c4 100644 --- a/src/components/asset/asset-list.test.tsx +++ b/src/components/asset/asset-list.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { AssetList } from './asset-list' import type { AssetInfo } from '@/types/asset' +import { Amount } from '@/types/amount' import { TestI18nProvider } from '@/test/i18n-mock' const renderWithI18n = (ui: React.ReactElement) => render({ui}) @@ -11,20 +12,20 @@ describe('AssetList', () => { { assetType: 'ETH', name: 'Ethereum', - amount: '1500000000000000000', + amount: Amount.fromRaw('1500000000000000000', 18, 'ETH'), decimals: 18, }, { assetType: 'USDT', name: 'Tether USD', - amount: '100000000', + amount: Amount.fromRaw('100000000', 6, 'USDT'), decimals: 6, contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', }, { assetType: 'BTC', name: 'Bitcoin', - amount: '50000000', + amount: Amount.fromRaw('50000000', 8, 'BTC'), decimals: 8, }, ] @@ -79,14 +80,14 @@ describe('AssetList', () => { { assetType: 'USDT', name: 'Tether (ETH)', - amount: '100000000', + amount: Amount.fromRaw('100000000', 6, 'USDT'), decimals: 6, contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', }, { assetType: 'USDT', name: 'Tether (TRX)', - amount: '200000000', + amount: Amount.fromRaw('200000000', 6, 'USDT'), decimals: 6, contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', }, diff --git a/src/components/transaction/transaction-item.stories.tsx b/src/components/transaction/transaction-item.stories.tsx index 4f686b665..359855612 100644 --- a/src/components/transaction/transaction-item.stories.tsx +++ b/src/components/transaction/transaction-item.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react' +import { Amount } from '@/types/amount' import { TransactionItem, type TransactionInfo } from './transaction-item' const meta: Meta = { @@ -14,7 +15,7 @@ const mockSend: TransactionInfo = { id: '1', type: 'send', status: 'confirmed', - amount: '100', + amount: Amount.fromFormatted('100', 6, 'USDT'), symbol: 'USDT', address: '0x1234567890abcdef1234567890abcdef12345678', timestamp: new Date(Date.now() - 3600000), @@ -24,7 +25,7 @@ const mockReceive: TransactionInfo = { id: '2', type: 'receive', status: 'confirmed', - amount: '0.5', + amount: Amount.fromFormatted('0.5', 18, 'ETH'), symbol: 'ETH', address: 'TRX9876543210fedcba9876543210fedcba', timestamp: new Date(Date.now() - 86400000), @@ -34,7 +35,7 @@ const mockPending: TransactionInfo = { id: '3', type: 'send', status: 'pending', - amount: '50', + amount: Amount.fromFormatted('50', 6, 'USDT'), symbol: 'USDT', address: '0xabcdef1234567890abcdef1234567890abcdef12', timestamp: new Date(), @@ -44,7 +45,7 @@ const mockFailed: TransactionInfo = { id: '4', type: 'send', status: 'failed', - amount: '200', + amount: Amount.fromFormatted('200', 6, 'TRX'), symbol: 'TRX', address: 'TRX1111222233334444555566667777888899', timestamp: new Date(Date.now() - 7200000), @@ -84,7 +85,7 @@ export const Swap: Story = { id: '5', type: 'swap', status: 'confirmed', - amount: '100', + amount: Amount.fromFormatted('100', 6, 'USDT'), symbol: 'USDT → ETH', address: '0xdex1234567890', timestamp: new Date(Date.now() - 1800000), @@ -105,7 +106,7 @@ export const AllTypes: Story = { id: '5', type: 'stake', status: 'confirmed', - amount: '1000', + amount: Amount.fromFormatted('1000', 8, 'BFM'), symbol: 'BFM', address: '质押合约', timestamp: new Date(Date.now() - 172800000), diff --git a/src/components/transaction/transaction-item.test.tsx b/src/components/transaction/transaction-item.test.tsx index c005b699b..87764194f 100644 --- a/src/components/transaction/transaction-item.test.tsx +++ b/src/components/transaction/transaction-item.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { TransactionItem, type TransactionInfo } from './transaction-item' +import { Amount } from '@/types/amount' import { TestI18nProvider } from '@/test/i18n-mock' // 包装组件以提供 i18n @@ -13,7 +14,7 @@ const mockTransaction: TransactionInfo = { id: '1', type: 'send', status: 'confirmed', - amount: '100', + amount: Amount.fromFormatted('100', 6, 'USDT'), symbol: 'USDT', address: '0x1234567890abcdef1234567890abcdef12345678', timestamp: new Date(), diff --git a/src/components/transaction/transaction-item.tsx b/src/components/transaction/transaction-item.tsx index 5448b5ba2..f2078cab3 100644 --- a/src/components/transaction/transaction-item.tsx +++ b/src/components/transaction/transaction-item.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import type { Amount } from '@/types/amount'; import { AddressDisplay } from '../wallet/address-display'; import { AmountDisplay, TimeDisplay } from '../common'; import { @@ -19,7 +20,7 @@ export interface TransactionInfo { id: string; type: TransactionType; status: TransactionStatus; - amount: string; + amount: Amount; symbol: string; address: string; timestamp: Date | string; @@ -54,6 +55,11 @@ export function TransactionItem({ transaction, onClick, className }: Transaction const isClickable = !!onClick; const Icon = typeIcon.Icon; + // Get the amount value for display + const amountValue = transaction.type === 'send' + ? -Math.abs(transaction.amount.toNumber()) + : transaction.amount.toNumber(); + return (
{ let secret: string try { @@ -80,8 +76,7 @@ export async function submitBioforestTransfer({ return { status: 'error', message: '签名地址不匹配' } } - const rawAmount = parseAmountToBigInt(amount, decimals) - if (rawAmount === null || rawAmount <= 0n) { + if (!amount.isPositive()) { return { status: 'error', message: '请输入有效金额' } } @@ -90,7 +85,7 @@ export async function submitBioforestTransfer({ const unsignedTx = await adapter.transaction.buildTransaction({ from: fromAddress, to: toAddress, - amount: rawAmount, + amount, }) const signedTx = await adapter.transaction.signTransaction(unsignedTx, hexToBytes(derived.privateKey)) const txHash = await adapter.transaction.broadcastTransaction(signedTx) diff --git a/src/hooks/use-send.constants.ts b/src/hooks/use-send.constants.ts index 74baa4000..30f0753f1 100644 --- a/src/hooks/use-send.constants.ts +++ b/src/hooks/use-send.constants.ts @@ -17,8 +17,7 @@ export const initialState: SendState = { amount: '', addressError: null, amountError: null, - feeAmount: '0', - feeAmountRaw: '0', + feeAmount: null, feeSymbol: '', feeLoading: false, isSubmitting: false, diff --git a/src/hooks/use-send.logic.ts b/src/hooks/use-send.logic.ts index 52fea4419..f4c4f98d8 100644 --- a/src/hooks/use-send.logic.ts +++ b/src/hooks/use-send.logic.ts @@ -1,5 +1,5 @@ import type { AssetInfo } from '@/types/asset' -import { Amount, amountFromAsset } from '@/types/amount' +import { Amount } from '@/types/amount' import { isValidBioforestAddress } from '@/lib/crypto' import { isValidAddress } from '@/components/transfer/address-input' @@ -22,8 +22,7 @@ export function validateAmountInput(amount: string, asset: AssetInfo | null): st const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) if (!inputAmount || !inputAmount.isPositive()) return '请输入有效金额' - const balance = amountFromAsset(asset) - if (inputAmount.gt(balance)) return '余额不足' + if (inputAmount.gt(asset.amount)) return '余额不足' return null } @@ -40,14 +39,12 @@ export function canProceedToConfirm(options: { const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) if (!inputAmount) return false - const balance = amountFromAsset(asset) - return ( toAddress.trim() !== '' && amount.trim() !== '' && isValidRecipientAddress(toAddress, isBioforestChain) && inputAmount.isPositive() && - inputAmount.lte(balance) + inputAmount.lte(asset.amount) ) } @@ -58,13 +55,12 @@ export type FeeAdjustResult = export function adjustAmountForFee( amount: string, asset: AssetInfo, - feeAmountRaw: string + fee: Amount ): FeeAdjustResult { const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) if (!inputAmount) return { status: 'error', message: '请输入有效金额' } - const balance = amountFromAsset(asset) - const fee = Amount.fromRaw(feeAmountRaw || '0', asset.decimals) + const balance = asset.amount if (inputAmount.add(fee).lte(balance)) return { status: 'ok' } if (!inputAmount.eq(balance)) return { status: 'error', message: '余额不足' } diff --git a/src/hooks/use-send.test.ts b/src/hooks/use-send.test.ts index 974e93eb4..0ef75bfb0 100644 --- a/src/hooks/use-send.test.ts +++ b/src/hooks/use-send.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' import { useSend } from './use-send' +import { Amount } from '@/types/amount' import type { AssetInfo } from '@/types/asset' const mockAsset: AssetInfo = { assetType: 'ETH', name: 'Ethereum', - amount: '1000000000000000000', // 1 ETH + amount: Amount.fromRaw('1000000000000000000', 18, 'ETH'), // 1 ETH decimals: 18, } @@ -105,7 +106,8 @@ describe('useSend', () => { }) expect(result.current.state.feeLoading).toBe(false) - expect(result.current.state.feeAmount).toBe('0.002') + expect(result.current.state.feeAmount).not.toBeNull() + expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002') expect(result.current.state.feeSymbol).toBe('ETH') }) }) diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index fff953593..6b8873acd 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -1,8 +1,8 @@ import { useState, useCallback, useMemo, useEffect } from 'react' import type { AssetInfo } from '@/types/asset' +import { Amount } from '@/types/amount' import { initialState, MOCK_FEES } from './use-send.constants' import type { SendState, UseSendOptions, UseSendReturn } from './use-send.types' -import { parseAmountToBigInt } from './use-send.utils' import { fetchBioforestBalance, fetchBioforestFee, submitBioforestTransfer } from './use-send.bioforest' import { adjustAmountForFee, canProceedToConfirm, validateAddressInput, validateAmountInput } from './use-send.logic' import { submitMockTransfer } from './use-send.mock' @@ -60,11 +60,10 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { // Mock fee estimation delay setTimeout(() => { const fee = MOCK_FEES[asset.assetType] ?? { amount: '0.001', symbol: asset.assetType } - const feeRaw = parseAmountToBigInt(fee.amount, asset.decimals) ?? 0n + const feeAmount = Amount.fromFormatted(fee.amount, asset.decimals, fee.symbol) setState((prev) => ({ ...prev, - feeAmount: fee.amount, - feeAmountRaw: feeRaw.toString(), + feeAmount: feeAmount, feeSymbol: fee.symbol, feeLoading: false, })) @@ -77,8 +76,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { const feeEstimate = await fetchBioforestFee(chainConfig, fromAddress) setState((prev) => ({ ...prev, - feeAmount: feeEstimate.formatted, - feeAmountRaw: feeEstimate.raw, + feeAmount: feeEstimate.amount, feeSymbol: feeEstimate.symbol, feeLoading: false, })) @@ -140,8 +138,8 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { return false } - if (state.asset) { - const adjustResult = adjustAmountForFee(state.amount, state.asset, state.feeAmountRaw) + if (state.asset && state.feeAmount) { + const adjustResult = adjustAmountForFee(state.amount, state.asset, state.feeAmount) if (adjustResult.status === 'error') { setState((prev) => ({ ...prev, @@ -164,7 +162,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { amountError: null, })) return true - }, [state.toAddress, state.amount, state.asset, state.feeAmountRaw, validateAddress, validateAmount]) + }, [state.toAddress, state.amount, state.asset, state.feeAmount, validateAddress, validateAmount]) // Go back to input const goBack = useCallback(() => { @@ -223,14 +221,16 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { errorMessage: null, })) + // Convert formatted amount string to Amount object + const sendAmount = Amount.fromFormatted(state.amount, state.asset.decimals, state.asset.assetType) + const result = await submitBioforestTransfer({ chainConfig, walletId, password, fromAddress, toAddress: state.toAddress, - amount: state.amount, - decimals: state.asset.decimals, + amount: sendAmount, }) if (result.status === 'password') { diff --git a/src/hooks/use-send.types.ts b/src/hooks/use-send.types.ts index 593844665..6d2eda642 100644 --- a/src/hooks/use-send.types.ts +++ b/src/hooks/use-send.types.ts @@ -1,4 +1,5 @@ import type { AssetInfo } from '@/types/asset' +import type { Amount } from '@/types/amount' import type { ChainConfig } from '@/services/chain-config' /** Send flow step */ @@ -22,9 +23,7 @@ export interface SendState { /** Amount validation error */ amountError: string | null /** Estimated fee amount */ - feeAmount: string - /** Estimated fee amount (raw smallest unit) */ - feeAmountRaw: string + feeAmount: Amount | null /** Fee token symbol */ feeSymbol: string /** Fee loading state */ diff --git a/src/hooks/use-transaction-history.test.ts b/src/hooks/use-transaction-history.test.ts index 4e237fb99..74910d04a 100644 --- a/src/hooks/use-transaction-history.test.ts +++ b/src/hooks/use-transaction-history.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' import { useTransactionHistory } from './use-transaction-history' +import { Amount } from '@/types/amount' import type { TransactionRecord as ServiceTransactionRecord } from '@/services/transaction' // Mock transaction data @@ -9,14 +10,16 @@ const mockTransactions: ServiceTransactionRecord[] = [ id: 'tx-1', type: 'send', status: 'confirmed', - amount: '1.5', + amount: Amount.fromFormatted('1.5', 18, 'ETH'), symbol: 'ETH', + decimals: 18, address: '0x1234567890abcdef1234567890abcdef12345678', timestamp: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // 1 day ago hash: '0xhash1', chain: 'ethereum', - fee: '0.001', + fee: Amount.fromFormatted('0.001', 18, 'ETH'), feeSymbol: 'ETH', + feeDecimals: 18, blockNumber: 18000000, confirmations: 10, }, @@ -24,14 +27,16 @@ const mockTransactions: ServiceTransactionRecord[] = [ id: 'tx-2', type: 'receive', status: 'confirmed', - amount: '100', + amount: Amount.fromFormatted('100', 6, 'USDT'), symbol: 'USDT', + decimals: 6, address: '0xabcdef1234567890abcdef1234567890abcdef12', timestamp: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), // 5 days ago hash: '0xhash2', chain: 'ethereum', - fee: '0.002', + fee: Amount.fromFormatted('0.002', 18, 'ETH'), feeSymbol: 'ETH', + feeDecimals: 18, blockNumber: 17999000, confirmations: 50, }, @@ -39,14 +44,16 @@ const mockTransactions: ServiceTransactionRecord[] = [ id: 'tx-3', type: 'send', status: 'pending', - amount: '50', + amount: Amount.fromFormatted('50', 6, 'TRX'), symbol: 'TRX', + decimals: 6, address: 'TXYZ1234567890', timestamp: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000), // 40 days ago hash: '0xhash3', chain: 'tron', - fee: '1', + fee: Amount.fromFormatted('1', 6, 'TRX'), feeSymbol: 'TRX', + feeDecimals: 6, blockNumber: undefined, confirmations: 0, }, diff --git a/src/hooks/use-transaction-history.ts b/src/hooks/use-transaction-history.ts index e0a0a54bf..d03179293 100644 --- a/src/hooks/use-transaction-history.ts +++ b/src/hooks/use-transaction-history.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from 'react' import type { ChainType } from '@/stores' import type { TransactionInfo } from '@/components/transaction/transaction-item' +import { Amount } from '@/types/amount' import { transactionService, type TransactionRecord as ServiceTransactionRecord, type TransactionFilter as ServiceFilter } from '@/services/transaction' /** 交易历史过滤器 */ @@ -12,7 +13,7 @@ export interface TransactionFilter { /** 扩展的交易信息(包含链类型)- 保持与组件兼容 */ export interface TransactionRecord extends TransactionInfo { chain: ChainType - fee: string | undefined + fee: Amount | undefined feeSymbol: string | undefined blockNumber: number | undefined confirmations: number | undefined @@ -72,8 +73,9 @@ export function useTransactionHistory(walletId?: string): UseTransactionHistoryR type: undefined, status: undefined, } - const records = await transactionService.getHistory({ walletId, filter: serviceFilter }) - setTransactions(records.map(convertToComponentFormat)) + const rawRecords = await transactionService.getHistory({ walletId, filter: serviceFilter }) + // Records from service already have Amount objects + setTransactions(rawRecords.map(convertToComponentFormat)) } catch (e) { setError(e instanceof Error ? e.message : '加载交易历史失败') } finally { diff --git a/src/pages/history/detail.test.tsx b/src/pages/history/detail.test.tsx index 868888de0..55e3c44ee 100644 --- a/src/pages/history/detail.test.tsx +++ b/src/pages/history/detail.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { Amount } from '@/types/amount' import { TransactionDetailPage } from './detail' import { TestI18nProvider } from '@/test/i18n-mock' @@ -32,26 +33,26 @@ vi.mock('@/stores', async (importOriginal) => { } }) -// Mock transaction history hook -const mockTransaction = { +// Create mock transaction with Amount objects +const createMockTransaction = () => ({ id: 'tx-1', type: 'send' as const, status: 'confirmed' as const, - amount: '1.5', + amount: Amount.fromFormatted('1.5', 18, 'ETH'), symbol: 'ETH', address: '0x1234567890abcdef1234567890abcdef12345678', timestamp: new Date('2025-12-10T10:00:00Z'), hash: '0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1', chain: 'ethereum' as const, - fee: '0.001', + fee: Amount.fromFormatted('0.001', 18, 'ETH'), feeSymbol: 'ETH', blockNumber: 18500000, confirmations: 50, -} +}) vi.mock('@/hooks/use-transaction-history', () => ({ useTransactionHistory: () => ({ - transactions: [mockTransaction], + transactions: [createMockTransaction()], isLoading: false, error: undefined, filter: { chain: 'all', period: 'all' }, @@ -147,7 +148,7 @@ describe('TransactionDetailPage', () => { const copyButton = screen.getByText('复制哈希') await userEvent.click(copyButton) - expect(mockClipboardWrite).toHaveBeenCalledWith({ text: mockTransaction.hash }) + expect(mockClipboardWrite).toHaveBeenCalledWith({ text: createMockTransaction().hash }) }) it('shows copied state after clicking', async () => { diff --git a/src/pages/history/detail.tsx b/src/pages/history/detail.tsx index a14996478..dacf0e04a 100644 --- a/src/pages/history/detail.tsx +++ b/src/pages/history/detail.tsx @@ -124,7 +124,7 @@ export function TransactionDetailPage() {

{t(`type.${transaction.type}`, { defaultValue: transaction.type })}

{t('detail.fee')} diff --git a/src/pages/history/index.test.tsx b/src/pages/history/index.test.tsx index 8450e52fe..25eb0b717 100644 --- a/src/pages/history/index.test.tsx +++ b/src/pages/history/index.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Amount } from '@/types/amount' import { TransactionHistoryPage } from './index' import { TestI18nProvider } from '@/test/i18n-mock' @@ -52,13 +53,13 @@ vi.mock('@/stores', async (importOriginal) => { } }) -// Mock transaction history hook -const mockTransactions = [ +// Create mock transactions with Amount objects +const createMockTransactions = () => [ { id: 'tx-1', type: 'send' as const, status: 'confirmed' as const, - amount: '1.5', + amount: Amount.fromFormatted('1.5', 18, 'ETH'), symbol: 'ETH', address: '0x1234567890abcdef1234567890abcdef12345678', timestamp: new Date(), @@ -69,7 +70,7 @@ const mockTransactions = [ id: 'tx-2', type: 'receive' as const, status: 'confirmed' as const, - amount: '100', + amount: Amount.fromFormatted('100', 6, 'USDT'), symbol: 'USDT', address: '0xabcdef1234567890abcdef1234567890abcdef12', timestamp: new Date(Date.now() - 86400000), // Yesterday @@ -81,7 +82,7 @@ const mockTransactions = [ const mockSetFilter = vi.fn() vi.mock('@/queries', () => ({ useTransactionHistoryQuery: () => ({ - transactions: mockTransactions, + transactions: createMockTransactions(), isLoading: false, isFetching: false, error: undefined, diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index 3ac34afac..078f502fe 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -12,7 +12,7 @@ import { TransferConfirmSheet } from '@/components/transfer/transfer-confirm-she import { SendResult } from '@/components/transfer/send-result'; import { useCamera, useToast, useHaptics } from '@/services'; import { useSend } from '@/hooks/use-send'; -import { formatAssetAmount } from '@/types/asset'; +import { Amount } from '@/types/amount'; import { IconChevronRight as ArrowRight } from '@tabler/icons-react'; import { useChainConfigState, @@ -65,10 +65,11 @@ export function SendPage() { const defaultAsset = useMemo(() => { if (!chainConfig) return null; const nativeBalance = currentChainAddress?.tokens.find((token) => token.symbol === chainConfig.symbol); + const balanceFormatted = nativeBalance?.balance ?? '0'; return { assetType: chainConfig.symbol, name: chainConfig.name, - amount: nativeBalance?.balance ?? '0', + amount: Amount.fromFormatted(balanceFormatted, chainConfig.decimals, chainConfig.symbol), decimals: chainConfig.decimals, }; }, [chainConfig, currentChainAddress?.tokens]); @@ -97,7 +98,7 @@ export function SendPage() { }, [initialAddress, initialAmount, state.toAddress, state.amount, setToAddress, setAmount]); // Derive formatted values - const balance = state.asset ? formatAssetAmount(state.asset.amount, state.asset.decimals) : '0'; + const balance = state.asset ? state.asset.amount.toFormatted() : '0'; const symbol = state.asset?.assetType ?? 'TOKEN'; const handleScan = async () => { @@ -250,7 +251,7 @@ export function SendPage() { amount={state.amount} symbol={symbol} toAddress={state.toAddress} - feeAmount={state.feeAmount} + feeAmount={state.feeAmount?.toFormatted() ?? '0'} feeSymbol={state.feeSymbol} feeLoading={state.feeLoading} isConfirming={state.isSubmitting} diff --git a/src/pages/staking/components/burn-form.tsx b/src/pages/staking/components/burn-form.tsx index 8d5781ec8..57f889d5a 100644 --- a/src/pages/staking/components/burn-form.tsx +++ b/src/pages/staking/components/burn-form.tsx @@ -11,6 +11,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { TokenIcon } from '@/components/token/token-icon'; import { LoadingSpinner } from '@/components/common/loading-spinner'; import { stakingService } from '@/services/staking'; +import { Amount } from '@/types/amount'; import type { ExternalChain, InternalChain, RechargeConfig, BurnRequest } from '@/types/staking'; import { cn } from '@/lib/utils'; @@ -192,10 +193,11 @@ export function BurnForm({ onSuccess, className }: BurnFormProps) { setIsSubmitting(true); try { + const cleanAmount = amount.replace(/,/g, ''); const request: BurnRequest = { sourceChain, sourceAsset, - amount: amount.replace(/,/g, ''), + amount: Amount.fromFormatted(cleanAmount, 8, sourceAsset), targetChain: targetChain as ExternalChain, targetAsset: sourceAsset, // Same asset type for 1:1 burn }; diff --git a/src/pages/staking/components/mint-form.tsx b/src/pages/staking/components/mint-form.tsx index ff59566de..629501193 100644 --- a/src/pages/staking/components/mint-form.tsx +++ b/src/pages/staking/components/mint-form.tsx @@ -11,6 +11,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { TokenIcon } from '@/components/token/token-icon'; import { LoadingSpinner } from '@/components/common/loading-spinner'; import { stakingService } from '@/services/staking'; +import { Amount } from '@/types/amount'; import type { ExternalChain, InternalChain, RechargeConfig, MintRequest } from '@/types/staking'; import { cn } from '@/lib/utils'; @@ -167,10 +168,11 @@ export function MintForm({ onSuccess, className }: MintFormProps) { setIsSubmitting(true); try { + const cleanAmount = amount.replace(/,/g, ''); const request: MintRequest = { sourceChain, sourceAsset, - amount: amount.replace(/,/g, ''), + amount: Amount.fromFormatted(cleanAmount, 18, sourceAsset), targetChain: targetChain as InternalChain, targetAsset, }; diff --git a/src/pages/staking/components/staking-pool-card.tsx b/src/pages/staking/components/staking-pool-card.tsx index 3c2bdbdcf..f1c727fd7 100644 --- a/src/pages/staking/components/staking-pool-card.tsx +++ b/src/pages/staking/components/staking-pool-card.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next' import { TokenIcon } from '@/components/token/token-icon' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import type { Amount } from '@/types/amount' import type { StakingOverviewItem } from '@/types/staking' interface StakingPoolCardProps { @@ -15,13 +16,12 @@ interface StakingPoolCardProps { } /** Format large numbers with decimals */ -function formatAmount(value: string, decimals = 5): string { - const num = parseFloat(value) - if (isNaN(num)) return '0.00' +function formatAmount(value: Amount): string { + const num = value.toNumber() if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B` if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M` if (num >= 1e3) return `${(num / 1e3).toFixed(2)}K` - return num.toFixed(Math.min(decimals, 8)) + return num.toFixed(Math.min(5, 8)) } /** Staking pool card component */ diff --git a/src/pages/staking/components/staking-record-list.test.tsx b/src/pages/staking/components/staking-record-list.test.tsx index f1381fa1a..de111d328 100644 --- a/src/pages/staking/components/staking-record-list.test.tsx +++ b/src/pages/staking/components/staking-record-list.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Amount } from '@/types/amount' import { StakingRecordList } from './staking-record-list' // Mock react-i18next @@ -23,44 +24,49 @@ vi.mock('react-i18next', () => ({ }), })) +// Create mock data with Amount objects +const createMockTransactions = () => [ + { + id: 'tx-001', + type: 'mint' as const, + sourceChain: 'BSC', + sourceAsset: 'USDT', + sourceAmount: Amount.fromRaw('1000000000000000000000', 18, 'USDT'), + targetChain: 'BFMeta', + targetAsset: 'USDT', + targetAmount: Amount.fromRaw('1000000000000000000000', 18, 'USDT'), + status: 'confirmed' as const, + txHash: '0x1234', + createdAt: Date.now() - 86400000, + updatedAt: Date.now() - 86400000 + 300000, + }, + { + id: 'tx-002', + type: 'burn' as const, + sourceChain: 'BFMeta', + sourceAsset: 'BFM', + sourceAmount: Amount.fromRaw('500000000000000000000', 18, 'BFM'), + targetChain: 'ETH', + targetAsset: 'BFM', + targetAmount: Amount.fromRaw('500000000000000000000', 18, 'BFM'), + status: 'pending' as const, + createdAt: Date.now() - 3600000, + updatedAt: Date.now() - 1800000, + }, +] + // Mock staking service vi.mock('@/services/staking', () => ({ stakingService: { - getTransactions: vi.fn().mockResolvedValue([ - { - id: 'tx-001', - type: 'mint', - sourceChain: 'BSC', - sourceAsset: 'USDT', - sourceAmount: '1000000000000000000000', - targetChain: 'BFMeta', - targetAsset: 'USDT', - targetAmount: '1000000000000000000000', - status: 'confirmed', - txHash: '0x1234', - createdAt: Date.now() - 86400000, - updatedAt: Date.now() - 86400000 + 300000, - }, - { - id: 'tx-002', - type: 'burn', - sourceChain: 'BFMeta', - sourceAsset: 'BFM', - sourceAmount: '500000000000000000000', - targetChain: 'ETH', - targetAsset: 'BFM', - targetAmount: '500000000000000000000', - status: 'pending', - createdAt: Date.now() - 3600000, - updatedAt: Date.now() - 1800000, - }, - ]), + getTransactions: vi.fn(), }, })) describe('StakingRecordList', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + const { stakingService } = await import('@/services/staking') + vi.mocked(stakingService.getTransactions).mockResolvedValue(createMockTransactions()) }) it('renders loading state initially', () => { diff --git a/src/pages/staking/components/staking-record-list.tsx b/src/pages/staking/components/staking-record-list.tsx index 479a9597f..b8ef89b36 100644 --- a/src/pages/staking/components/staking-record-list.tsx +++ b/src/pages/staking/components/staking-record-list.tsx @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'; import { TokenIcon } from '@/components/token/token-icon'; import { LoadingSpinner } from '@/components/common/loading-spinner'; import { stakingService } from '@/services/staking'; +import type { Amount } from '@/types/amount'; import type { StakingTransaction, StakingTxType, StakingTxStatus } from '@/types/staking'; import { cn } from '@/lib/utils'; @@ -68,8 +69,8 @@ function formatTime(timestamp: number): string { } /** Format amount with decimals */ -function formatAmount(raw: string, decimals = 18): string { - const num = parseFloat(raw) / Math.pow(10, decimals); +function formatAmount(amount: Amount): string { + const num = amount.toNumber(); if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M`; if (num >= 1000) return `${(num / 1000).toFixed(2)}K`; return num.toFixed(4); diff --git a/src/pages/staking/index.test.tsx b/src/pages/staking/index.test.tsx index cbc4a4d45..f9a55a186 100644 --- a/src/pages/staking/index.test.tsx +++ b/src/pages/staking/index.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { Amount } from '@/types/amount' import { StakingPage } from './index' // Mock react-i18next @@ -49,24 +50,27 @@ vi.mock('@/stackflow', () => ({ useActivityParams: () => ({}), })) +// Create mock data with Amount objects +const createMockOverview = () => [ + { + chain: 'BFMeta' as const, + assetType: 'BFM', + stakedAmount: Amount.fromFormatted('10000', 18, 'BFM'), + stakedFiat: '$1,234.56', + availableChains: ['ETH', 'BSC'] as ('ETH' | 'BSC' | 'TRON')[], + totalMinted: Amount.fromRaw('1250000000000000000000000', 18, 'BFM'), + totalCirculation: Amount.fromRaw('980000000000000000000000', 18, 'BFM'), + totalBurned: Amount.fromRaw('270000000000000000000000', 18, 'BFM'), + totalStaked: Amount.fromRaw('1250000000000000000000000', 18, 'BFM'), + externalChain: 'BSC' as const, + externalAssetType: 'BFM', + }, +] + // Mock staking service vi.mock('@/services/staking', () => ({ stakingService: { - getOverview: vi.fn().mockResolvedValue([ - { - chain: 'BFMeta', - assetType: 'BFM', - stakedAmount: '10,000.00', - stakedFiat: '$1,234.56', - availableChains: ['ETH', 'BSC'], - totalMinted: '1250000', - totalCirculation: '980000', - totalBurned: '270000', - totalStaked: '1250000', - externalChain: 'BSC', - externalAssetType: 'BFM', - }, - ]), + getOverview: vi.fn(), getRechargeConfig: vi.fn().mockResolvedValue({ bfmeta: { BFM: { @@ -97,8 +101,10 @@ vi.mock('@/services/staking', () => ({ })) describe('StakingPage', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + const { stakingService } = await import('@/services/staking') + vi.mocked(stakingService.getOverview).mockResolvedValue(createMockOverview()) }) it('renders page header with title', () => { diff --git a/src/pages/token/detail.tsx b/src/pages/token/detail.tsx index ea9858cec..3da1546fb 100644 --- a/src/pages/token/detail.tsx +++ b/src/pages/token/detail.tsx @@ -5,6 +5,7 @@ import { BalanceDisplay } from '@/components/token/balance-display'; import { TransactionList } from '@/components/transaction/transaction-list'; import { GradientButton } from '@/components/common/gradient-button'; import { IconArrowUp as ArrowUp, IconArrowDown as ArrowDown } from '@tabler/icons-react'; +import { Amount } from '@/types/amount'; // 临时模拟数据 const mockToken = { @@ -20,7 +21,7 @@ const mockTransactions = [ id: '1', type: 'send' as const, status: 'confirmed' as const, - amount: '100', + amount: Amount.fromFormatted('100', 6, 'USDT'), symbol: 'USDT', address: '0xabcd...1234', timestamp: new Date(Date.now() - 3600000), @@ -29,7 +30,7 @@ const mockTransactions = [ id: '2', type: 'receive' as const, status: 'confirmed' as const, - amount: '500', + amount: Amount.fromFormatted('500', 6, 'USDT'), symbol: 'USDT', address: '0x1234...abcd', timestamp: new Date(Date.now() - 86400000), diff --git a/src/queries/use-transaction-history-query.ts b/src/queries/use-transaction-history-query.ts index b78e98f78..f2645e6a0 100644 --- a/src/queries/use-transaction-history-query.ts +++ b/src/queries/use-transaction-history-query.ts @@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import type { ChainType } from '@/stores' import type { TransactionInfo } from '@/components/transaction/transaction-item' +import type { Amount } from '@/types/amount' import { transactionService, type TransactionRecord as ServiceTransactionRecord, @@ -17,7 +18,7 @@ export interface TransactionFilter { /** 扩展的交易信息(包含链类型)- 保持与组件兼容 */ export interface TransactionRecord extends TransactionInfo { chain: ChainType - fee: string | undefined + fee: Amount | undefined feeSymbol: string | undefined blockNumber: number | undefined confirmations: number | undefined diff --git a/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts b/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts index 1bd31716c..4fb12bdfc 100644 --- a/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts +++ b/src/services/chain-adapter/__tests__/bioforest-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest' import type { ChainConfig } from '@/services/chain-config' +import { Amount } from '@/types/amount' import { createBioforestKeypair, publicKeyToBioforestAddress, verifySignature, hexToBytes } from '@/lib/crypto' import { BioforestAdapter, createBioforestAdapter } from '../bioforest' import { getAdapterRegistry, resetAdapterRegistry, setupAdapters } from '../index' @@ -161,7 +162,7 @@ describe('BioforestTransactionService', () => { const fees = await adapter.transaction.estimateFee({ from: validAddress, to: recipientAddress, - amount: 1000000n, + amount: Amount.fromRaw('1000000', 8, 'BFM'), }) expect(fees.slow).toBeDefined() @@ -176,7 +177,7 @@ describe('BioforestTransactionService', () => { const tx = await adapter.transaction.buildTransaction({ from: validAddress, to: recipientAddress, - amount: 1000000n, + amount: Amount.fromRaw('1000000', 8, 'BFM'), memo: 'test transfer', }) @@ -196,7 +197,7 @@ describe('BioforestTransactionService', () => { const tx = await adapter.transaction.buildTransaction({ from: validAddress, to: recipientAddress, - amount: 1000000n, + amount: Amount.fromRaw('1000000', 8, 'BFM'), }) const signedTx = await adapter.transaction.signTransaction(tx, testKeypair.secretKey) @@ -210,7 +211,7 @@ describe('BioforestTransactionService', () => { const tx = await adapter.transaction.buildTransaction({ from: validAddress, to: recipientAddress, - amount: 1000000n, + amount: Amount.fromRaw('1000000', 8, 'BFM'), }) const signedTx = await adapter.transaction.signTransaction(tx, testKeypair.secretKey) @@ -239,9 +240,9 @@ describe('BioforestTransactionService', () => { it('returns mock balance when no RPC configured', async () => { const balance = await adapter.asset.getNativeBalance(validAddress) - expect(balance.raw).toBe(0n) + expect(balance.amount.isZero()).toBe(true) expect(balance.symbol).toBe('BFM') - expect(balance.decimals).toBe(8) + expect(balance.amount.decimals).toBe(8) }) }) diff --git a/src/services/chain-adapter/bioforest/asset-service.ts b/src/services/chain-adapter/bioforest/asset-service.ts index f0f192d87..1d70a1bc7 100644 --- a/src/services/chain-adapter/bioforest/asset-service.ts +++ b/src/services/chain-adapter/bioforest/asset-service.ts @@ -5,6 +5,7 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { Amount } from '@/types/amount' import type { IAssetService, Address, Balance, TokenMetadata } from '../types' import { ChainServiceError, ChainErrorCodes } from '../types' @@ -57,10 +58,8 @@ export class BioforestAssetService implements IAssetService { if (native) return native return { - raw: 0n, - formatted: '0', + amount: Amount.zero(this.config.decimals, this.config.symbol), symbol: this.config.symbol, - decimals: this.config.decimals, } } @@ -73,10 +72,8 @@ export class BioforestAssetService implements IAssetService { if (token) return token return { - raw: 0n, - formatted: '0', + amount: Amount.zero(this.config.decimals, assetType), symbol: assetType, - decimals: this.config.decimals, } } @@ -85,10 +82,8 @@ export class BioforestAssetService implements IAssetService { // No RPC URL configured, return empty balance return [ { - raw: 0n, - formatted: '0', + amount: Amount.zero(this.config.decimals, this.config.symbol), symbol: this.config.symbol, - decimals: this.config.decimals, }, ] } @@ -117,10 +112,8 @@ export class BioforestAssetService implements IAssetService { // API returned success=false or no result, return empty return [ { - raw: 0n, - formatted: '0', + amount: Amount.zero(this.config.decimals, this.config.symbol), symbol: this.config.symbol, - decimals: this.config.decimals, }, ] } @@ -137,15 +130,13 @@ export class BioforestAssetService implements IAssetService { const asset = magicAssets[assetType] if (!asset) continue - const raw = BigInt(asset.assetNumber) // BioForest chains use fixed 8 decimals const decimals = this.config.decimals + const amount = Amount.fromRaw(asset.assetNumber, decimals, asset.assetType) balances.push({ - raw, - formatted: this.formatAmount(raw, decimals), + amount, symbol: asset.assetType, - decimals, }) } } @@ -154,10 +145,8 @@ export class BioforestAssetService implements IAssetService { if (balances.length === 0) { return [ { - raw: 0n, - formatted: '0', + amount: Amount.zero(this.config.decimals, this.config.symbol), symbol: this.config.symbol, - decimals: this.config.decimals, }, ] } @@ -183,14 +172,4 @@ export class BioforestAssetService implements IAssetService { decimals: this.config.decimals, } } - - private formatAmount(raw: bigint, decimals: number): string { - const divisor = BigInt(10 ** decimals) - const integerPart = raw / divisor - const fractionalPart = raw % divisor - - // 始终显示完整的小数位(如 0.00000000) - const fractionalStr = fractionalPart.toString().padStart(decimals, '0') - return `${integerPart}.${fractionalStr}` - } } diff --git a/src/services/chain-adapter/bioforest/chain-service.ts b/src/services/chain-adapter/bioforest/chain-service.ts index 134978bbe..daab1d069 100644 --- a/src/services/chain-adapter/bioforest/chain-service.ts +++ b/src/services/chain-adapter/bioforest/chain-service.ts @@ -3,6 +3,7 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { Amount } from '@/types/amount' import type { IChainService, ChainInfo, GasPrice, HealthStatus } from '../types' import { ChainServiceError, ChainErrorCodes } from '../types' import type { BioforestBlockInfo, BioforestFeeInfo } from './types' @@ -63,13 +64,15 @@ export class BioforestChainService implements IChainService { } async getGasPrice(): Promise { + const { decimals, symbol } = this.config + if (!this.baseUrl) { // Return default fees - const defaultFee = BigInt(10000000) // 0.1 in 8 decimals + const defaultFee = Amount.fromRaw('10000000', decimals, symbol) // 0.1 in 8 decimals return { slow: defaultFee, standard: defaultFee, - fast: defaultFee * 2n, + fast: defaultFee.mul(2), lastUpdated: Date.now(), } } @@ -88,23 +91,23 @@ export class BioforestChainService implements IChainService { } const data = (await response.json()) as { data: BioforestFeeInfo } - const minFee = BigInt(data.data.minFee) - const avgFee = BigInt(data.data.avgFee) + const minFee = Amount.fromRaw(data.data.minFee, decimals, symbol) + const avgFee = Amount.fromRaw(data.data.avgFee, decimals, symbol) return { slow: minFee, - standard: avgFee > minFee ? avgFee : minFee, - fast: avgFee * 2n > minFee ? avgFee * 2n : minFee * 2n, + standard: avgFee.gt(minFee) ? avgFee : minFee, + fast: avgFee.mul(2).gt(minFee) ? avgFee.mul(2) : minFee.mul(2), lastUpdated: Date.now(), } } catch (error) { if (error instanceof ChainServiceError) throw error // Return default on error - const defaultFee = BigInt(10000000) + const defaultFee = Amount.fromRaw('10000000', decimals, symbol) return { slow: defaultFee, standard: defaultFee, - fast: defaultFee * 2n, + fast: defaultFee.mul(2), lastUpdated: Date.now(), } } diff --git a/src/services/chain-adapter/bioforest/transaction-service.ts b/src/services/chain-adapter/bioforest/transaction-service.ts index 2e4596f5b..615c5821c 100644 --- a/src/services/chain-adapter/bioforest/transaction-service.ts +++ b/src/services/chain-adapter/bioforest/transaction-service.ts @@ -6,6 +6,7 @@ */ import type { ChainConfig } from '@/services/chain-config' +import { Amount } from '@/types/amount' import type { ITransactionService, TransferParams, @@ -36,9 +37,8 @@ export class BioforestTransactionService implements ITransactionService { async estimateFee(_params: TransferParams): Promise { const gasPrice = await this.chainService.getGasPrice() - const createFee = (amount: bigint, time: number): Fee => ({ + const createFee = (amount: Amount, time: number): Fee => ({ amount, - formatted: this.formatAmount(amount), estimatedTime: time, }) @@ -64,9 +64,9 @@ export class BioforestTransactionService implements ITransactionService { type: 'transfer', from: params.from, to: params.to, - amount: params.amount.toString(), + amount: params.amount.toRawString(), assetType: this.config.symbol, - fee: feeEstimate.standard.amount.toString(), + fee: feeEstimate.standard.amount.toRawString(), memo: params.memo, timestamp: Date.now(), }, @@ -235,12 +235,14 @@ export class BioforestTransactionService implements ITransactionService { const tx = result.data.transaction if (!tx) return null + const { decimals, symbol } = this.config + return { hash: tx.signature, from: tx.senderId, to: tx.recipientId, - amount: BigInt(tx.amount), - fee: BigInt(tx.fee), + amount: Amount.fromRaw(tx.amount, decimals, symbol), + fee: Amount.fromRaw(tx.fee, decimals, symbol), status: { status: tx.height ? 'confirmed' : 'pending', confirmations: tx.height ? 1 : 0, @@ -292,12 +294,14 @@ export class BioforestTransactionService implements ITransactionService { } } + const { decimals, symbol } = this.config + return result.data.transactions.map((tx) => ({ hash: tx.signature, from: tx.senderId, to: tx.recipientId, - amount: BigInt(tx.amount), - fee: BigInt(tx.fee), + amount: Amount.fromRaw(tx.amount, decimals, symbol), + fee: Amount.fromRaw(tx.fee, decimals, symbol), status: { status: tx.height ? 'confirmed' : 'pending', confirmations: tx.height ? 1 : 0, @@ -311,18 +315,4 @@ export class BioforestTransactionService implements ITransactionService { return [] } } - - private formatAmount(raw: bigint): string { - const decimals = this.config.decimals - const divisor = BigInt(10 ** decimals) - const integerPart = raw / divisor - const fractionalPart = raw % divisor - - if (fractionalPart === 0n) { - return integerPart.toString() - } - - const fractionalStr = fractionalPart.toString().padStart(decimals, '0').replace(/0+$/, '') - return `${integerPart}.${fractionalStr}` - } } diff --git a/src/services/chain-adapter/types.ts b/src/services/chain-adapter/types.ts index 00af14dd8..4eb1906cd 100644 --- a/src/services/chain-adapter/types.ts +++ b/src/services/chain-adapter/types.ts @@ -3,6 +3,7 @@ */ import type { ChainConfig, ChainConfigType } from '@/services/chain-config' +import { Amount } from '@/types/amount' // ==================== 基础类型 ==================== @@ -13,10 +14,8 @@ export type Signature = string // ==================== 余额 ==================== export interface Balance { - raw: bigint - formatted: string + amount: Amount symbol: string - decimals: number } // ==================== 代币 ==================== @@ -38,8 +37,7 @@ export interface FeeEstimate { } export interface Fee { - amount: bigint - formatted: string + amount: Amount estimatedTime: number // seconds } @@ -48,7 +46,7 @@ export interface Fee { export interface TransferParams { from: Address to: Address - amount: bigint + amount: Amount tokenAddress?: Address memo?: string } @@ -77,8 +75,8 @@ export interface Transaction { hash: TransactionHash from: Address to: Address - amount: bigint - fee: bigint + amount: Amount + fee: Amount status: TransactionStatus timestamp: number blockNumber?: bigint | undefined @@ -101,10 +99,10 @@ export interface ChainInfo { } export interface GasPrice { - slow: bigint - standard: bigint - fast: bigint - baseFee?: bigint + slow: Amount + standard: Amount + fast: Amount + baseFee?: Amount | undefined lastUpdated: number } diff --git a/src/services/staking/mock.ts b/src/services/staking/mock.ts index 484859919..abad8bbda 100644 --- a/src/services/staking/mock.ts +++ b/src/services/staking/mock.ts @@ -2,6 +2,7 @@ * Staking 服务 - Mock 实现 */ +import { Amount } from '@/types/amount' import { stakingServiceMeta, type IStakingMockController } from './types' import type { RechargeConfig, @@ -55,42 +56,42 @@ const initialOverview: StakingOverviewItem[] = [ { chain: 'BFMeta', assetType: 'BFM', - stakedAmount: '10,000.00', + stakedAmount: Amount.fromFormatted('10000', 18, 'BFM'), stakedFiat: '$1,234.56', availableChains: ['ETH', 'BSC'], logoUrl: '/tokens/bfm.svg', - totalMinted: '1250000', - totalCirculation: '980000', - totalBurned: '270000', - totalStaked: '1250000', + totalMinted: Amount.fromRaw('1250000000000000000000000', 18, 'BFM'), + totalCirculation: Amount.fromRaw('980000000000000000000000', 18, 'BFM'), + totalBurned: Amount.fromRaw('270000000000000000000000', 18, 'BFM'), + totalStaked: Amount.fromRaw('1250000000000000000000000', 18, 'BFM'), externalChain: 'BSC', externalAssetType: 'BFM', }, { chain: 'BFMeta', assetType: 'USDT', - stakedAmount: '5,000.00', + stakedAmount: Amount.fromFormatted('5000', 6, 'USDT'), stakedFiat: '$5,000.00', availableChains: ['ETH', 'BSC', 'TRON'], logoUrl: '/tokens/usdt.svg', - totalMinted: '8500000', - totalCirculation: '7200000', - totalBurned: '1300000', - totalStaked: '8500000', + totalMinted: Amount.fromRaw('8500000000000', 6, 'USDT'), + totalCirculation: Amount.fromRaw('7200000000000', 6, 'USDT'), + totalBurned: Amount.fromRaw('1300000000000', 6, 'USDT'), + totalStaked: Amount.fromRaw('8500000000000', 6, 'USDT'), externalChain: 'ETH', externalAssetType: 'USDT', }, { chain: 'BFChain', assetType: 'BFC', - stakedAmount: '2,500.00', + stakedAmount: Amount.fromFormatted('2500', 18, 'BFC'), stakedFiat: '$625.00', availableChains: ['BSC'], logoUrl: '/tokens/bfc.svg', - totalMinted: '450000', - totalCirculation: '380000', - totalBurned: '70000', - totalStaked: '450000', + totalMinted: Amount.fromRaw('450000000000000000000000', 18, 'BFC'), + totalCirculation: Amount.fromRaw('380000000000000000000000', 18, 'BFC'), + totalBurned: Amount.fromRaw('70000000000000000000000', 18, 'BFC'), + totalStaked: Amount.fromRaw('450000000000000000000000', 18, 'BFC'), externalChain: 'BSC', externalAssetType: 'BFC', }, @@ -102,10 +103,10 @@ const initialTransactions: StakingTransaction[] = [ type: 'mint', sourceChain: 'BSC', sourceAsset: 'USDT', - sourceAmount: '1000000000000000000000', + sourceAmount: Amount.fromRaw('1000000000000000000000', 18, 'USDT'), targetChain: 'BFMeta', targetAsset: 'USDT', - targetAmount: '1000000000000000000000', + targetAmount: Amount.fromRaw('1000000000000000000000', 18, 'USDT'), status: 'confirmed', txHash: '0x1234567890abcdef1234567890abcdef12345678', createdAt: Date.now() - 86400000, @@ -116,10 +117,10 @@ const initialTransactions: StakingTransaction[] = [ type: 'burn', sourceChain: 'BFMeta', sourceAsset: 'BFM', - sourceAmount: '500000000000000000000', + sourceAmount: Amount.fromRaw('500000000000000000000', 18, 'BFM'), targetChain: 'ETH', targetAsset: 'BFM', - targetAmount: '500000000000000000000', + targetAmount: Amount.fromRaw('500000000000000000000', 18, 'BFM'), status: 'confirming', createdAt: Date.now() - 3600000, updatedAt: Date.now() - 1800000, diff --git a/src/services/staking/types.ts b/src/services/staking/types.ts index aff477e60..f09ccccd1 100644 --- a/src/services/staking/types.ts +++ b/src/services/staking/types.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { defineServiceMeta } from '@/lib/service-meta' +import { Amount } from '@/types/amount' import type { RechargeConfig, StakingTransaction, @@ -15,6 +16,9 @@ import type { // ==================== Zod Schemas ==================== +// Custom Amount schema for Zod validation +const AmountSchema = z.custom((val) => val instanceof Amount) + const RechargeConfigSchema = z.record(z.string(), z.record(z.string(), z.object({ assetType: z.string(), logo: z.string().optional(), @@ -30,14 +34,14 @@ const LogoUrlMapSchema = z.record(z.string(), z.record(z.string(), z.string())) const StakingOverviewItemSchema = z.object({ chain: z.enum(['BFMeta', 'BFChain', 'CCChain', 'PMChain']), assetType: z.string(), - stakedAmount: z.string(), + stakedAmount: AmountSchema, stakedFiat: z.string(), availableChains: z.array(z.enum(['ETH', 'BSC', 'TRON'])), logoUrl: z.string().optional(), - totalMinted: z.string(), - totalCirculation: z.string(), - totalBurned: z.string(), - totalStaked: z.string(), + totalMinted: AmountSchema, + totalCirculation: AmountSchema, + totalBurned: AmountSchema, + totalStaked: AmountSchema, externalChain: z.enum(['ETH', 'BSC', 'TRON']), externalAssetType: z.string(), }) @@ -47,10 +51,10 @@ const StakingTransactionSchema = z.object({ type: z.enum(['mint', 'burn']), sourceChain: z.string(), sourceAsset: z.string(), - sourceAmount: z.string(), + sourceAmount: AmountSchema, targetChain: z.string(), targetAsset: z.string(), - targetAmount: z.string(), + targetAmount: AmountSchema, status: z.enum(['pending', 'confirming', 'confirmed', 'failed']), txHash: z.string().optional(), createdAt: z.number(), @@ -61,7 +65,7 @@ const StakingTransactionSchema = z.object({ const MintRequestSchema = z.object({ sourceChain: z.enum(['ETH', 'BSC', 'TRON']), sourceAsset: z.string(), - amount: z.string(), + amount: AmountSchema, targetChain: z.enum(['BFMeta', 'BFChain', 'CCChain', 'PMChain']), targetAsset: z.string(), }) @@ -69,7 +73,7 @@ const MintRequestSchema = z.object({ const BurnRequestSchema = z.object({ sourceChain: z.enum(['BFMeta', 'BFChain', 'CCChain', 'PMChain']), sourceAsset: z.string(), - amount: z.string(), + amount: AmountSchema, targetChain: z.enum(['ETH', 'BSC', 'TRON']), targetAsset: z.string(), }) diff --git a/src/services/transaction/mock.ts b/src/services/transaction/mock.ts index c0ba0a784..60fb66d3f 100644 --- a/src/services/transaction/mock.ts +++ b/src/services/transaction/mock.ts @@ -2,6 +2,7 @@ * Transaction 服务 - Mock 实现 */ +import { Amount } from '@/types/amount' import { transactionServiceMeta, type ITransactionMockController, type TransactionRecord, type TransactionFilter, type TransactionType, type TransactionStatus } from './types' import type { ChainType } from '@/stores' @@ -21,6 +22,12 @@ function randomHash(): string { return hash } +// Decimals for different tokens +const TOKEN_DECIMALS: Record = { + ETH: 18, USDT: 6, USDC: 6, TRX: 6, BTC: 8, BNB: 18, BUSD: 18, BFM: 8, + BFC: 8, CC: 8, PM: 8, BTG: 8, BIW: 8, ETHM: 18, MAL: 8, TOKEN: 18, +} + function generateInitialTransactions(count: number = 30): TransactionRecord[] { const types: TransactionType[] = ['send', 'receive', 'swap', 'stake', 'unstake'] const statuses: TransactionStatus[] = ['confirmed', 'confirmed', 'confirmed', 'pending', 'failed'] @@ -49,22 +56,30 @@ function generateInitialTransactions(count: number = 30): TransactionRecord[] { const type = types[Math.floor(Math.random() * types.length)]! const status = statuses[Math.floor(Math.random() * statuses.length)]! const symbol = chainSymbols[Math.floor(Math.random() * chainSymbols.length)]! + const decimals = TOKEN_DECIMALS[symbol] ?? 18 + const feeSymbol = chain === 'ethereum' ? 'ETH' : chain === 'tron' ? 'TRX' : symbol + const feeDecimals = TOKEN_DECIMALS[feeSymbol] ?? 18 const daysAgo = Math.floor(Math.random() * 90) const timestamp = new Date(now - daysAgo * 24 * 60 * 60 * 1000) + const amountStr = (Math.random() * 100).toFixed(type === 'swap' ? 2 : 4) + const feeStr = (Math.random() * 0.01).toFixed(6) + transactions.push({ id: `tx-${i}-${Date.now()}`, type, status, - amount: (Math.random() * 100).toFixed(type === 'swap' ? 2 : 4), + amount: Amount.fromFormatted(amountStr, decimals, symbol), symbol, + decimals, address: randomAddress(), timestamp, hash: randomHash(), chain, - fee: (Math.random() * 0.01).toFixed(6), - feeSymbol: chain === 'ethereum' ? 'ETH' : chain === 'tron' ? 'TRX' : symbol, + fee: Amount.fromFormatted(feeStr, feeDecimals, feeSymbol), + feeSymbol, + feeDecimals, blockNumber: status === 'confirmed' ? Math.floor(Math.random() * 1000000) + 18000000 : undefined, confirmations: status === 'confirmed' ? Math.floor(Math.random() * 100) + 1 : 0, }) diff --git a/src/services/transaction/types.ts b/src/services/transaction/types.ts index dd7e466d8..1c7afd907 100644 --- a/src/services/transaction/types.ts +++ b/src/services/transaction/types.ts @@ -4,8 +4,12 @@ import { z } from 'zod' import { defineServiceMeta } from '@/lib/service-meta' +import { Amount } from '@/types/amount' import type { ChainType } from '@/stores' +// Custom Amount schema for Zod validation +const AmountSchema = z.custom((val) => val instanceof Amount) + /** 交易类型 */ export type TransactionType = 'send' | 'receive' | 'swap' | 'stake' | 'unstake' @@ -17,14 +21,16 @@ export interface TransactionRecord { id: string type: TransactionType status: TransactionStatus - amount: string + amount: Amount symbol: string + decimals: number address: string timestamp: Date hash?: string | undefined chain: ChainType - fee?: string | undefined + fee?: Amount | undefined feeSymbol?: string | undefined + feeDecimals?: number | undefined blockNumber?: number | undefined confirmations?: number | undefined } @@ -37,20 +43,22 @@ export interface TransactionFilter { status: TransactionStatus | 'all' | undefined } -// ==================== Zod Schemas ==================== +// ==================== Zod Schemas (for API validation) ==================== const TransactionRecordSchema = z.object({ id: z.string(), type: z.enum(['send', 'receive', 'swap', 'stake', 'unstake']), status: z.enum(['pending', 'confirmed', 'failed']), - amount: z.string(), + amount: AmountSchema, symbol: z.string(), + decimals: z.number(), address: z.string(), timestamp: z.date(), hash: z.string().optional(), chain: z.string(), - fee: z.string().optional(), + fee: AmountSchema.optional(), feeSymbol: z.string().optional(), + feeDecimals: z.number().optional(), blockNumber: z.number().optional(), confirmations: z.number().optional(), }) diff --git a/src/services/transaction/web.ts b/src/services/transaction/web.ts index d954cfca2..aa52d9020 100644 --- a/src/services/transaction/web.ts +++ b/src/services/transaction/web.ts @@ -6,7 +6,6 @@ import { transactionServiceMeta, type TransactionFilter, type TransactionRecord, import { walletStorageService } from '@/services/wallet-storage' import { initialize as initializeChainConfigs, getEnabledChains, getChainById, type ChainConfig } from '@/services/chain-config' import { createBioforestAdapter, type Transaction as ChainTransaction } from '@/services/chain-adapter' -import { formatAssetAmount } from '@/types/asset' const recordCache = new Map() type TransactionFilterInput = Partial | undefined @@ -65,21 +64,21 @@ function mapType(tx: ChainTransaction, address: string): TransactionType { function mapChainTransaction(tx: ChainTransaction, config: ChainConfig, address: string): TransactionRecord { const type = mapType(tx, address) - const amount = formatAssetAmount(tx.amount.toString(), config.decimals) - const fee = formatAssetAmount(tx.fee.toString(), config.decimals) return { id: `${config.id}:${tx.hash}`, type, status: mapStatus(tx.status), - amount, + amount: tx.amount, symbol: config.symbol, + decimals: config.decimals, address: type === 'send' ? tx.to : tx.from, timestamp: new Date(tx.timestamp), hash: tx.hash, chain: config.id, - fee, + fee: tx.fee, feeSymbol: config.symbol, + feeDecimals: config.decimals, blockNumber: tx.blockNumber ? Number(tx.blockNumber) : undefined, confirmations: tx.status.confirmations, } diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index f06cb0b0d..6d832409d 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -455,10 +455,10 @@ export const walletActions = { id: `${chain}:${b.symbol}`, symbol: b.symbol, name: b.symbol, - balance: b.formatted, + balance: b.amount.toFormatted(), fiatValue: 0, // TODO: 对接汇率服务 change24h: 0, - decimals: b.decimals, + decimals: b.amount.decimals, chain, })) diff --git a/src/types/asset.test.ts b/src/types/asset.test.ts index c148b03f2..02a141037 100644 --- a/src/types/asset.test.ts +++ b/src/types/asset.test.ts @@ -1,88 +1,90 @@ import { describe, it, expect } from 'vitest' +import { Amount } from './amount' import { - formatAssetAmount, formatFiatValue, formatPriceChange, convertFiat, } from './asset' -describe('formatAssetAmount', () => { +describe('Amount.toFormatted (replaces formatAssetAmount)', () => { it('formats zero amount', () => { - expect(formatAssetAmount('0', 18)).toBe('0') - expect(formatAssetAmount('', 18)).toBe('0') + expect(Amount.zero(18).toFormatted()).toBe('0') + expect(Amount.fromRaw('0', 18).toFormatted()).toBe('0') }) it('formats integer amounts', () => { // 1 ETH = 1000000000000000000 wei - expect(formatAssetAmount('1000000000000000000', 18)).toBe('1') + expect(Amount.fromRaw('1000000000000000000', 18).toFormatted()).toBe('1') // 100 USDT = 100000000 (6 decimals) - expect(formatAssetAmount('100000000', 6)).toBe('100') + expect(Amount.fromRaw('100000000', 6).toFormatted()).toBe('100') }) it('formats decimal amounts', () => { // 1.5 ETH - expect(formatAssetAmount('1500000000000000000', 18)).toBe('1.5') + expect(Amount.fromRaw('1500000000000000000', 18).toFormatted()).toBe('1.5') // 0.5 BTC - expect(formatAssetAmount('50000000', 8)).toBe('0.5') + expect(Amount.fromRaw('50000000', 8).toFormatted()).toBe('0.5') // 10.25 USDT - expect(formatAssetAmount('10250000', 6)).toBe('10.25') + expect(Amount.fromRaw('10250000', 6).toFormatted()).toBe('10.25') }) it('trims trailing zeros', () => { // 1.10 should be 1.1 - expect(formatAssetAmount('1100000000000000000', 18)).toBe('1.1') + expect(Amount.fromRaw('1100000000000000000', 18).toFormatted()).toBe('1.1') // 2.500 should be 2.5 - expect(formatAssetAmount('2500000', 6)).toBe('2.5') + expect(Amount.fromRaw('2500000', 6).toFormatted()).toBe('2.5') }) it('handles small amounts', () => { // 0.001 ETH - expect(formatAssetAmount('1000000000000000', 18)).toBe('0.001') + expect(Amount.fromRaw('1000000000000000', 18).toFormatted()).toBe('0.001') // 0.000001 USDT - expect(formatAssetAmount('1', 6)).toBe('0.000001') + expect(Amount.fromRaw('1', 6).toFormatted()).toBe('0.000001') }) it('handles large amounts', () => { // 1000000 ETH - expect(formatAssetAmount('1000000000000000000000000', 18)).toBe('1000000') + expect(Amount.fromRaw('1000000000000000000000000', 18).toFormatted()).toBe('1000000') // 999999.999999 USDT - expect(formatAssetAmount('999999999999', 6)).toBe('999999.999999') + expect(Amount.fromRaw('999999999999', 6).toFormatted()).toBe('999999.999999') }) }) describe('formatFiatValue', () => { it('formats USD values', () => { - // 1 ETH at $2500 = $2500.00 - expect(formatFiatValue('1000000000000000000', 18, 2500, 'USD')).toBe('$2,500.00') + const amount = Amount.fromRaw('1000000000000000000', 18) + expect(formatFiatValue(amount, 18, 2500, 'USD')).toBe('$2,500.00') }) it('formats small values', () => { - // 0.5 ETH at $2500 = $1250.00 - expect(formatFiatValue('500000000000000000', 18, 2500, 'USD')).toBe('$1,250.00') + const amount = Amount.fromRaw('500000000000000000', 18) + expect(formatFiatValue(amount, 18, 2500, 'USD')).toBe('$1,250.00') }) it('returns $0.00 for zero amount', () => { - expect(formatFiatValue('0', 18, 2500, 'USD')).toBe('$0.00') - expect(formatFiatValue('', 18, 2500, 'USD')).toBe('$0.00') + expect(formatFiatValue(Amount.zero(18), 18, 2500, 'USD')).toBe('$0.00') }) it('returns $0.00 when price is zero', () => { - expect(formatFiatValue('1000000000000000000', 18, 0, 'USD')).toBe('$0.00') + const amount = Amount.fromRaw('1000000000000000000', 18) + expect(formatFiatValue(amount, 18, 0, 'USD')).toBe('$0.00') }) it('formats CNY values', () => { - // 1 ETH at $2500 in CNY - const result = formatFiatValue('1000000000000000000', 18, 2500, 'CNY') + const amount = Amount.fromRaw('1000000000000000000', 18) + const result = formatFiatValue(amount, 18, 2500, 'CNY') expect(result).toContain('2,500.00') }) it('formats EUR values', () => { - const result = formatFiatValue('1000000000000000000', 18, 2500, 'EUR') + const amount = Amount.fromRaw('1000000000000000000', 18) + const result = formatFiatValue(amount, 18, 2500, 'EUR') expect(result).toContain('2.500,00') // German locale uses comma for decimals }) it('defaults to USD for unknown currency', () => { - expect(formatFiatValue('1000000000000000000', 18, 2500, 'UNKNOWN')).toBe('$2,500.00') + const amount = Amount.fromRaw('1000000000000000000', 18) + expect(formatFiatValue(amount, 18, 2500, 'UNKNOWN')).toBe('$2,500.00') }) }) @@ -129,9 +131,8 @@ describe('convertFiat', () => { describe('formatFiatValue with exchange rate', () => { it('applies exchange rate when currency is not USD', () => { - // 1 ETH at $2500, convert to CNY at rate 7.2 - // Expected: 2500 * 7.2 = 18000 CNY - const result = formatFiatValue('1000000000000000000', 18, 2500, { + const amount = Amount.fromRaw('1000000000000000000', 18) + const result = formatFiatValue(amount, 18, 2500, { currency: 'CNY', exchangeRate: 7.2, }) @@ -139,8 +140,8 @@ describe('formatFiatValue with exchange rate', () => { }) it('ignores exchange rate when currency is USD', () => { - // Even with exchange rate provided, USD should not be converted - const result = formatFiatValue('1000000000000000000', 18, 2500, { + const amount = Amount.fromRaw('1000000000000000000', 18) + const result = formatFiatValue(amount, 18, 2500, { currency: 'USD', exchangeRate: 7.2, }) @@ -148,35 +149,34 @@ describe('formatFiatValue with exchange rate', () => { }) it('works without exchange rate (legacy behavior)', () => { - // Without exchange rate, just formats the USD value with target currency symbol - const result = formatFiatValue('1000000000000000000', 18, 2500, { + const amount = Amount.fromRaw('1000000000000000000', 18) + const result = formatFiatValue(amount, 18, 2500, { currency: 'CNY', }) expect(result).toContain('2,500.00') }) it('supports legacy string parameter', () => { - // Legacy call with string currency parameter - const result = formatFiatValue('1000000000000000000', 18, 2500, 'USD') + const amount = Amount.fromRaw('1000000000000000000', 18) + const result = formatFiatValue(amount, 18, 2500, 'USD') expect(result).toBe('$2,500.00') }) it('uses custom locale when provided', () => { - const result = formatFiatValue('1000000000000000000', 18, 2500, { + const amount = Amount.fromRaw('1000000000000000000', 18) + const result = formatFiatValue(amount, 18, 2500, { currency: 'EUR', exchangeRate: 0.92, locale: 'en-US', }) - // Using en-US locale for EUR should give different format expect(result).toContain('2,300.00') }) it('formats zero with target currency symbol', () => { - const result = formatFiatValue('0', 18, 2500, { + const result = formatFiatValue(Amount.zero(18), 18, 2500, { currency: 'CNY', exchangeRate: 7.2, }) - // Should show CNY formatted zero, not USD expect(result).toContain('0.00') }) }) diff --git a/src/types/asset.ts b/src/types/asset.ts index 39bbf9561..5d95482d0 100644 --- a/src/types/asset.ts +++ b/src/types/asset.ts @@ -3,12 +3,14 @@ * Based on mpay $AddressAssetsTypeV2 pattern */ +import { Amount } from './amount' + /** Single asset/token information */ export interface AssetInfo { /** Token identifier (ETH, USDT, BFM, etc.) */ assetType: string - /** Balance as string for decimal precision */ - amount: string + /** Balance as Amount object */ + amount: Amount /** Decimal places for formatting */ decimals: number /** Token icon URL (optional, fallback to letter) */ @@ -43,26 +45,6 @@ export interface AssetState { error: string | null } -/** Format raw amount with decimals */ -export function formatAssetAmount(amount: string, decimals: number): string { - if (!amount || amount === '0') return '0' - - const value = BigInt(amount) - const divisor = BigInt(10 ** decimals) - const integerPart = value / divisor - const fractionalPart = value % divisor - - if (fractionalPart === 0n) { - return integerPart.toString() - } - - const fractionalStr = fractionalPart.toString().padStart(decimals, '0') - // Trim trailing zeros - const trimmed = fractionalStr.replace(/0+$/, '') - - return `${integerPart}.${trimmed}` -} - /** Currency configuration for fiat formatting */ export interface CurrencyConfig { code: string @@ -100,59 +82,43 @@ export interface FormatFiatOptions { /** * Calculate and format fiat value from asset amount and price - * - * Overload 1: Legacy signature (backward compatible) - * @param amount Raw amount string (in smallest unit) - * @param decimals Token decimal places + * @param amount Amount object or raw string + * @param decimals Token decimal places (only used if amount is string) * @param priceUsd Price per token in USD - * @param currency Target currency code (default: USD) + * @param options Formatting options * @returns Formatted fiat value string (e.g., "$1,234.56") */ export function formatFiatValue( - amount: string, + amount: Amount | string, decimals: number, priceUsd: number, - currency?: string -): string -/** - * Overload 2: Extended signature with options object - * @param amount Raw amount string (in smallest unit) - * @param decimals Token decimal places - * @param priceUsd Price per token in USD - * @param options Formatting options including currency and exchange rate - * @returns Formatted fiat value string - */ -export function formatFiatValue( - amount: string, - decimals: number, - priceUsd: number, - options?: FormatFiatOptions -): string -export function formatFiatValue( - amount: string, - decimals: number, - priceUsd: number, - currencyOrOptions?: string | FormatFiatOptions + options?: FormatFiatOptions | string ): string { - // Parse options - support both legacy string and new options object - const options: FormatFiatOptions = - typeof currencyOrOptions === 'string' - ? { currency: currencyOrOptions } - : currencyOrOptions ?? {} + const opts: FormatFiatOptions = + typeof options === 'string' ? { currency: options } : options ?? {} - const { currency = 'USD', exchangeRate, locale } = options + const { currency = 'USD', exchangeRate, locale } = opts - // Get currency config with fallback to USD const config: CurrencyConfig = currencyConfigs[currency] ?? { code: 'USD', locale: 'en-US', } - // Use provided locale or default to currency's locale const formatLocale = locale ?? config.locale - // Handle zero/empty values - format with target currency - if (!amount || amount === '0' || !priceUsd) { + // Convert Amount or string to number + let value: number + if (amount instanceof Amount) { + value = amount.toNumber() + } else { + if (!amount || amount === '0') { + value = 0 + } else { + value = Number(amount) / 10 ** decimals + } + } + + if (value === 0 || !priceUsd) { return new Intl.NumberFormat(formatLocale, { style: 'currency', currency: config.code, @@ -161,16 +127,12 @@ export function formatFiatValue( }).format(0) } - // Convert to number for calculation - const value = Number(amount) / 10 ** decimals let fiatValue = value * priceUsd - // Apply exchange rate if provided and currency is not USD if (exchangeRate !== undefined && currency !== 'USD') { fiatValue = convertFiat(fiatValue, exchangeRate) } - // Format with Intl.NumberFormat return new Intl.NumberFormat(formatLocale, { style: 'currency', currency: config.code, @@ -189,3 +151,28 @@ export function formatPriceChange(change: number | undefined): string { const sign = change >= 0 ? '+' : '' return `${sign}${change.toFixed(2)}%` } + +/** + * Create AssetInfo from raw data (e.g., from API) + */ +export function createAssetInfo(data: { + assetType: string + amount: string // raw or formatted string + decimals: number + logoUrl?: string + contractAddress?: string + name?: string + priceUsd?: number + priceChange24h?: number +}): AssetInfo { + return { + assetType: data.assetType, + amount: Amount.parse(data.amount, data.decimals, data.assetType), + decimals: data.decimals, + logoUrl: data.logoUrl, + contractAddress: data.contractAddress, + name: data.name, + priceUsd: data.priceUsd, + priceChange24h: data.priceChange24h, + } +} diff --git a/src/types/staking.test.ts b/src/types/staking.test.ts index 0aba7ce75..c58afb82b 100644 --- a/src/types/staking.test.ts +++ b/src/types/staking.test.ts @@ -3,6 +3,7 @@ */ import { describe, it, expect } from 'vitest' +import { Amount } from './amount' import { ExternalChainSchema, InternalChainSchema, @@ -11,9 +12,6 @@ import { RechargeConfigItemSchema, StakingTxTypeSchema, StakingTxStatusSchema, - StakingTransactionSchema, - MintRequestSchema, - BurnRequestSchema, type ExternalChain, type InternalChain, type StakingTransaction, @@ -133,66 +131,57 @@ describe('Staking Types', () => { }) }) - describe('StakingTransactionSchema', () => { - it('validates complete transaction', () => { + describe('StakingTransaction interface', () => { + it('validates complete transaction with Amount', () => { const tx: StakingTransaction = { id: 'tx-001', type: 'mint', sourceChain: 'BSC', sourceAsset: 'USDT', - sourceAmount: '1000000000000000000', + sourceAmount: Amount.fromRaw('1000000000000000000', 18, 'USDT'), targetChain: 'BFMeta', targetAsset: 'USDT', - targetAmount: '1000000000000000000', + targetAmount: Amount.fromRaw('1000000000000000000', 18, 'USDT'), status: 'confirmed', txHash: '0x1234567890abcdef', createdAt: Date.now(), updatedAt: Date.now(), } - expect(StakingTransactionSchema.parse(tx)).toEqual(tx) + + expect(tx.id).toBe('tx-001') + expect(tx.type).toBe('mint') + expect(tx.sourceAmount.toRawString()).toBe('1000000000000000000') }) it('allows optional txHash and errorMessage', () => { - const tx = { + const tx: StakingTransaction = { id: 'tx-002', type: 'burn', sourceChain: 'BFMeta', sourceAsset: 'BFM', - sourceAmount: '500000000', + sourceAmount: Amount.fromFormatted('500', 8, 'BFM'), targetChain: 'ETH', targetAsset: 'BFM', - targetAmount: '500000000', + targetAmount: Amount.fromFormatted('500', 18, 'BFM'), status: 'pending', createdAt: Date.now(), updatedAt: Date.now(), } - expect(StakingTransactionSchema.parse(tx)).toEqual(tx) + + expect(tx.txHash).toBeUndefined() + expect(tx.errorMessage).toBeUndefined() }) }) - describe('MintRequestSchema', () => { - it('validates mint request', () => { - const request = { - sourceChain: 'ETH' as ExternalChain, - sourceAsset: 'USDT', - amount: '1000000', - targetChain: 'BFMeta' as InternalChain, - targetAsset: 'USDT', - } - expect(MintRequestSchema.parse(request)).toEqual(request) + describe('Type inference', () => { + it('ExternalChain type is correct', () => { + const chain: ExternalChain = 'ETH' + expect(['ETH', 'BSC', 'TRON']).toContain(chain) }) - }) - describe('BurnRequestSchema', () => { - it('validates burn request', () => { - const request = { - sourceChain: 'BFMeta' as InternalChain, - sourceAsset: 'BFM', - amount: '1000000000', - targetChain: 'BSC' as ExternalChain, - targetAsset: 'BFM', - } - expect(BurnRequestSchema.parse(request)).toEqual(request) + it('InternalChain type is correct', () => { + const chain: InternalChain = 'BFMeta' + expect(['BFMeta', 'BFChain', 'CCChain', 'PMChain']).toContain(chain) }) }) }) diff --git a/src/types/staking.ts b/src/types/staking.ts index 1c2745e2a..74c4d5f66 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -4,6 +4,7 @@ */ import { z } from 'zod' +import { Amount } from './amount' /** Supported external chains for staking */ export const ExternalChainSchema = z.enum(['ETH', 'BSC', 'TRON']) @@ -64,65 +65,62 @@ export const StakingTxStatusSchema = z.enum(['pending', 'confirming', 'confirmed export type StakingTxStatus = z.infer /** Staking transaction record */ -export const StakingTransactionSchema = z.object({ +export interface StakingTransaction { /** Unique transaction ID */ - id: z.string(), + id: string /** Transaction type (mint/burn) */ - type: StakingTxTypeSchema, + type: StakingTxType /** Source chain */ - sourceChain: z.string(), + sourceChain: string /** Source asset type */ - sourceAsset: z.string(), - /** Source amount (raw, in smallest unit) */ - sourceAmount: z.string(), + sourceAsset: string + /** Source amount */ + sourceAmount: Amount /** Target chain */ - targetChain: z.string(), + targetChain: string /** Target asset type */ - targetAsset: z.string(), - /** Target amount (raw, in smallest unit) */ - targetAmount: z.string(), + targetAsset: string + /** Target amount */ + targetAmount: Amount /** Transaction status */ - status: StakingTxStatusSchema, + status: StakingTxStatus /** Transaction hash (on-chain) */ - txHash: z.string().optional(), + txHash?: string | undefined /** Creation timestamp */ - createdAt: z.number(), + createdAt: number /** Last update timestamp */ - updatedAt: z.number(), + updatedAt: number /** Error message if failed */ - errorMessage: z.string().optional(), -}) -export type StakingTransaction = z.infer + errorMessage?: string | undefined +} /** Mint (stake) request */ -export const MintRequestSchema = z.object({ +export interface MintRequest { /** Source external chain */ - sourceChain: ExternalChainSchema, + sourceChain: ExternalChain /** Source asset type */ - sourceAsset: z.string(), - /** Amount to mint (raw, in smallest unit) */ - amount: z.string(), + sourceAsset: string + /** Amount to mint */ + amount: Amount /** Target internal chain */ - targetChain: InternalChainSchema, + targetChain: InternalChain /** Target asset type */ - targetAsset: z.string(), -}) -export type MintRequest = z.infer + targetAsset: string +} /** Burn (unstake) request */ -export const BurnRequestSchema = z.object({ +export interface BurnRequest { /** Source internal chain */ - sourceChain: InternalChainSchema, + sourceChain: InternalChain /** Source asset type */ - sourceAsset: z.string(), - /** Amount to burn (raw, in smallest unit) */ - amount: z.string(), + sourceAsset: string + /** Amount to burn */ + amount: Amount /** Target external chain */ - targetChain: ExternalChainSchema, + targetChain: ExternalChain /** Target asset type */ - targetAsset: z.string(), -}) -export type BurnRequest = z.infer + targetAsset: string +} /** Staking overview item */ export interface StakingOverviewItem { @@ -130,22 +128,22 @@ export interface StakingOverviewItem { chain: InternalChain /** Asset type */ assetType: string - /** Staked amount (formatted) */ - stakedAmount: string - /** Staked amount in fiat */ + /** Staked amount */ + stakedAmount: Amount + /** Staked amount in fiat (formatted string for display) */ stakedFiat: string /** Available external chains for unstaking */ availableChains: ExternalChain[] /** Logo URL */ logoUrl?: string | undefined /** Total amount minted (pool stat) */ - totalMinted: string + totalMinted: Amount /** Total amount in circulation (pool stat) */ - totalCirculation: string + totalCirculation: Amount /** Total amount burned/redeemed (pool stat) */ - totalBurned: string + totalBurned: Amount /** Total amount staked on external chain (pool stat) */ - totalStaked: string + totalStaked: Amount /** External chain name */ externalChain: ExternalChain /** External asset type */