From f2909edaa28f7bb280b6567adee813537809450c Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 22 Dec 2025 17:10:49 +0800 Subject: [PATCH] feat: add Amount class for type-safe monetary operations Problem: - BigInt(asset.amount) throws when amount contains decimals - No clear distinction between raw (smallest unit) and formatted values - Easy to make mistakes when handling monetary values Solution: - Add Amount class with immutable design - Factory methods: fromRaw(), fromFormatted(), parse() (auto-detect) - Comparison: gt(), lt(), eq(), gte(), lte() - Arithmetic: add(), sub(), mul(), div() - Conversion: toRawString(), toFormatted(), toNumber() - Helper: amountFromAsset() for smart parsing Usage: ```ts // From chain data (raw) const balance = Amount.fromRaw('1000000000000000000', 18) // From user input (formatted) const input = Amount.fromFormatted('1.5', 18) // Auto-detect (handles both) const amount = Amount.parse(asset.amount, asset.decimals) // Compare and operate if (input.gt(balance)) throw new Error('Insufficient balance') const remaining = balance.sub(input) ``` Fixes SendPage BigInt error with '102531.02649070' --- src/hooks/use-send.logic.ts | 51 +++--- src/types/amount.test.ts | 211 +++++++++++++++++++++++++ src/types/amount.ts | 300 ++++++++++++++++++++++++++++++++++++ 3 files changed, 541 insertions(+), 21 deletions(-) create mode 100644 src/types/amount.test.ts create mode 100644 src/types/amount.ts diff --git a/src/hooks/use-send.logic.ts b/src/hooks/use-send.logic.ts index fe81986c6..52fea4419 100644 --- a/src/hooks/use-send.logic.ts +++ b/src/hooks/use-send.logic.ts @@ -1,8 +1,7 @@ import type { AssetInfo } from '@/types/asset' -import { formatAssetAmount } from '@/types/asset' +import { Amount, amountFromAsset } from '@/types/amount' import { isValidBioforestAddress } from '@/lib/crypto' import { isValidAddress } from '@/components/transfer/address-input' -import { parseAmountToBigInt } from './use-send.utils' export function isValidRecipientAddress(address: string, isBioforestChain: boolean): boolean { if (!address.trim()) return false @@ -19,10 +18,13 @@ export function validateAddressInput(address: string, isBioforestChain: boolean) export function validateAmountInput(amount: string, asset: AssetInfo | null): string | null { if (!amount.trim()) return '请输入金额' if (!asset) return null - const rawAmount = parseAmountToBigInt(amount, asset.decimals) - if (rawAmount === null || rawAmount <= 0n) return '请输入有效金额' - const balance = BigInt(asset.amount) - if (rawAmount > balance) return '余额不足' + + const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) + if (!inputAmount || !inputAmount.isPositive()) return '请输入有效金额' + + const balance = amountFromAsset(asset) + if (inputAmount.gt(balance)) return '余额不足' + return null } @@ -34,15 +36,18 @@ export function canProceedToConfirm(options: { }): boolean { const { toAddress, amount, asset, isBioforestChain } = options if (!asset) return false - const rawAmount = parseAmountToBigInt(amount, asset.decimals) - const balance = BigInt(asset.amount) + + const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) + if (!inputAmount) return false + + const balance = amountFromAsset(asset) + return ( toAddress.trim() !== '' && amount.trim() !== '' && isValidRecipientAddress(toAddress, isBioforestChain) && - rawAmount !== null && - rawAmount > 0n && - rawAmount <= balance + inputAmount.isPositive() && + inputAmount.lte(balance) ) } @@ -50,21 +55,25 @@ export type FeeAdjustResult = | { status: 'ok'; adjustedAmount?: string } | { status: 'error'; message: string } -export function adjustAmountForFee(amount: string, asset: AssetInfo, feeAmountRaw: string): FeeAdjustResult { - const rawAmount = parseAmountToBigInt(amount, asset.decimals) - if (rawAmount === null) return { status: 'error', message: '请输入有效金额' } +export function adjustAmountForFee( + amount: string, + asset: AssetInfo, + feeAmountRaw: string +): FeeAdjustResult { + const inputAmount = Amount.tryFromFormatted(amount, asset.decimals) + if (!inputAmount) return { status: 'error', message: '请输入有效金额' } - const balance = BigInt(asset.amount) - const feeRaw = BigInt(feeAmountRaw || '0') + const balance = amountFromAsset(asset) + const fee = Amount.fromRaw(feeAmountRaw || '0', asset.decimals) - if (rawAmount + feeRaw <= balance) return { status: 'ok' } - if (rawAmount !== balance) return { status: 'error', message: '余额不足' } + if (inputAmount.add(fee).lte(balance)) return { status: 'ok' } + if (!inputAmount.eq(balance)) return { status: 'error', message: '余额不足' } - const maxSendable = balance - feeRaw - if (maxSendable <= 0n) return { status: 'error', message: '余额不足' } + const maxSendable = balance.sub(fee) + if (!maxSendable.isPositive()) return { status: 'error', message: '余额不足' } return { status: 'ok', - adjustedAmount: formatAssetAmount(maxSendable.toString(), asset.decimals), + adjustedAmount: maxSendable.toFormatted(), } } diff --git a/src/types/amount.test.ts b/src/types/amount.test.ts new file mode 100644 index 000000000..3560e045e --- /dev/null +++ b/src/types/amount.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest' +import { Amount, amountFromAsset } from './amount' + +describe('Amount', () => { + describe('fromRaw', () => { + it('should create from bigint', () => { + const amount = Amount.fromRaw(1000000000000000000n, 18, 'ETH') + expect(amount.raw).toBe(1000000000000000000n) + expect(amount.decimals).toBe(18) + expect(amount.symbol).toBe('ETH') + }) + + it('should create from string', () => { + const amount = Amount.fromRaw('1000000000000000000', 18) + expect(amount.raw).toBe(1000000000000000000n) + }) + }) + + describe('fromFormatted', () => { + it('should parse whole number', () => { + const amount = Amount.fromFormatted('100', 18) + expect(amount.raw).toBe(100000000000000000000n) + }) + + it('should parse decimal number', () => { + const amount = Amount.fromFormatted('1.5', 18) + expect(amount.raw).toBe(1500000000000000000n) + }) + + it('should parse small decimal', () => { + const amount = Amount.fromFormatted('0.001', 18) + expect(amount.raw).toBe(1000000000000000n) + }) + + it('should throw on invalid format', () => { + expect(() => Amount.fromFormatted('abc', 18)).toThrow('Invalid amount format') + }) + + it('should throw on too many decimals', () => { + expect(() => Amount.fromFormatted('1.0000000000000000001', 18)).toThrow() + }) + }) + + describe('tryFromFormatted', () => { + it('should return Amount on valid input', () => { + const amount = Amount.tryFromFormatted('1.5', 18) + expect(amount).not.toBeNull() + expect(amount?.toFormatted()).toBe('1.5') + }) + + it('should return null on invalid input', () => { + expect(Amount.tryFromFormatted('abc', 18)).toBeNull() + expect(Amount.tryFromFormatted('', 18)).toBeNull() + }) + }) + + describe('parse (smart parsing)', () => { + it('should parse raw value (no decimal point)', () => { + const amount = Amount.parse('1000000000000000000', 18) + expect(amount.toFormatted()).toBe('1') + }) + + it('should parse formatted value (with decimal point)', () => { + const amount = Amount.parse('102531.02649070', 8) // 问题中的实际值 + expect(amount.toFormatted()).toBe('102531.0264907') + }) + + it('should handle the SendPage error case', () => { + // 这是导致 BUG 的实际值 + const amount = Amount.parse('102531.02649070', 8) + expect(amount.isPositive()).toBe(true) + expect(amount.raw).toBe(10253102649070n) + }) + }) + + describe('toFormatted', () => { + it('should format whole number', () => { + const amount = Amount.fromRaw(1000000000000000000n, 18) + expect(amount.toFormatted()).toBe('1') + }) + + it('should format decimal with trimmed zeros', () => { + const amount = Amount.fromRaw(1500000000000000000n, 18) + expect(amount.toFormatted()).toBe('1.5') + }) + + it('should keep trailing zeros when specified', () => { + const amount = Amount.fromRaw(1500000000000000000n, 18) + expect(amount.toFormatted({ trimTrailingZeros: false })).toBe('1.500000000000000000') + }) + + it('should format zero', () => { + expect(Amount.zero(18).toFormatted()).toBe('0') + }) + }) + + describe('comparison', () => { + const a = Amount.fromFormatted('1.5', 18) + const b = Amount.fromFormatted('2.0', 18) + const c = Amount.fromFormatted('1.5', 18) + + it('gt', () => { + expect(b.gt(a)).toBe(true) + expect(a.gt(b)).toBe(false) + }) + + it('gte', () => { + expect(b.gte(a)).toBe(true) + expect(a.gte(c)).toBe(true) + }) + + it('lt', () => { + expect(a.lt(b)).toBe(true) + expect(b.lt(a)).toBe(false) + }) + + it('lte', () => { + expect(a.lte(b)).toBe(true) + expect(a.lte(c)).toBe(true) + }) + + it('eq', () => { + expect(a.eq(c)).toBe(true) + expect(a.eq(b)).toBe(false) + }) + + it('should throw on different decimals', () => { + const x = Amount.fromFormatted('1', 18) + const y = Amount.fromFormatted('1', 8) + expect(() => x.gt(y)).toThrow('decimals mismatch') + }) + }) + + describe('arithmetic', () => { + it('add', () => { + const a = Amount.fromFormatted('1.5', 18) + const b = Amount.fromFormatted('2.5', 18) + expect(a.add(b).toFormatted()).toBe('4') + }) + + it('sub', () => { + const a = Amount.fromFormatted('5', 18) + const b = Amount.fromFormatted('1.5', 18) + expect(a.sub(b).toFormatted()).toBe('3.5') + }) + + it('mul by integer', () => { + const a = Amount.fromFormatted('1.5', 18) + expect(a.mul(2n).toFormatted()).toBe('3') + }) + + it('mul by float', () => { + const a = Amount.fromFormatted('10', 18) + expect(a.mul(0.5).toFormatted()).toBe('5') + }) + + it('div by integer', () => { + const a = Amount.fromFormatted('10', 18) + expect(a.div(2n).toFormatted()).toBe('5') + }) + + it('min/max', () => { + const a = Amount.fromFormatted('1', 18) + const b = Amount.fromFormatted('2', 18) + expect(a.min(b).eq(a)).toBe(true) + expect(a.max(b).eq(b)).toBe(true) + }) + }) + + describe('utility methods', () => { + it('isZero', () => { + expect(Amount.zero(18).isZero()).toBe(true) + expect(Amount.fromFormatted('1', 18).isZero()).toBe(false) + }) + + it('isPositive', () => { + expect(Amount.fromFormatted('1', 18).isPositive()).toBe(true) + expect(Amount.zero(18).isPositive()).toBe(false) + }) + + it('toNumber', () => { + const amount = Amount.fromFormatted('1.5', 18) + expect(amount.toNumber()).toBe(1.5) + }) + + it('toString with symbol', () => { + const amount = Amount.fromFormatted('1.5', 18, 'ETH') + expect(amount.toString()).toBe('1.5 ETH') + }) + + it('withSymbol', () => { + const amount = Amount.fromFormatted('1.5', 18) + expect(amount.withSymbol('ETH').symbol).toBe('ETH') + }) + }) + + describe('amountFromAsset', () => { + it('should handle raw amount (no decimal)', () => { + const asset = { amount: '1000000000000000000', decimals: 18, assetType: 'ETH' } + const amount = amountFromAsset(asset) + expect(amount.toFormatted()).toBe('1') + }) + + it('should handle formatted amount (with decimal)', () => { + const asset = { amount: '102531.02649070', decimals: 8, assetType: 'BTC' } + const amount = amountFromAsset(asset) + expect(amount.isPositive()).toBe(true) + expect(amount.toFormatted()).toBe('102531.0264907') + }) + }) +}) diff --git a/src/types/amount.ts b/src/types/amount.ts new file mode 100644 index 000000000..31d746fcb --- /dev/null +++ b/src/types/amount.ts @@ -0,0 +1,300 @@ +/** + * Amount - 不可变的金额类型 + * + * 设计原则: + * 1. 内部始终使用 bigint 存储原始值(最小单位) + * 2. 不可变 - 所有操作返回新实例 + * 3. 类型安全 - 通过静态工厂方法创建,防止混淆 raw/formatted + * 4. 自文档化 - 方法名清晰表达意图 + * + * 使用示例: + * ```ts + * // 从原始值创建(如链上返回的余额) + * const balance = Amount.fromRaw('1000000000000000000', 18, 'ETH') + * + * // 从用户输入创建(如输入框的金额) + * const input = Amount.fromFormatted('1.5', 18, 'ETH') + * + * // 比较 + * if (input.gt(balance)) { console.log('余额不足') } + * + * // 运算 + * const remaining = balance.sub(input) + * + * // 显示 + * console.log(remaining.toFormatted()) // "0.5" + * ``` + */ +export class Amount { + private readonly _raw: bigint + private readonly _decimals: number + private readonly _symbol: string | undefined + + private constructor(raw: bigint, decimals: number, symbol: string | undefined) { + this._raw = raw + this._decimals = decimals + this._symbol = symbol + } + + // ==================== 静态工厂方法 ==================== + + /** + * 从原始值创建(最小单位,如 wei、lamports、satoshi) + * 用于:链上返回的余额、交易金额等 + */ + static fromRaw(raw: bigint | string, decimals: number, symbol?: string): Amount { + const value = typeof raw === 'string' ? BigInt(raw) : raw + return new Amount(value, decimals, symbol) + } + + /** + * 从格式化值创建(带小数的人类可读格式) + * 用于:用户输入、显示值转换 + * @throws 如果输入格式无效 + */ + static fromFormatted(formatted: string, decimals: number, symbol?: string): Amount { + const raw = Amount.parseFormatted(formatted, decimals) + if (raw === null) { + throw new Error(`Invalid amount format: "${formatted}"`) + } + return new Amount(raw, decimals, symbol) + } + + /** + * 尝试从格式化值创建,失败返回 null + * 用于:用户输入验证 + */ + static tryFromFormatted(formatted: string, decimals: number, symbol?: string): Amount | null { + const raw = Amount.parseFormatted(formatted, decimals) + if (raw === null) return null + return new Amount(raw, decimals, symbol) + } + + /** + * 创建零值 + */ + static zero(decimals: number, symbol?: string): Amount { + return new Amount(0n, decimals, symbol) + } + + /** + * 智能解析 - 自动判断是 raw 还是 formatted + * 规则:如果包含小数点,则为 formatted;否则为 raw + * 用于:处理来源不确定的数据(如 API 返回) + */ + static parse(value: string, decimals: number, symbol?: string): Amount { + if (value.includes('.')) { + return Amount.fromFormatted(value, decimals, symbol) + } + return Amount.fromRaw(value, decimals, symbol) + } + + // ==================== Getters ==================== + + /** 原始值(bigint) */ + get raw(): bigint { + return this._raw + } + + /** 精度(小数位数) */ + get decimals(): number { + return this._decimals + } + + /** 符号/单位 */ + get symbol(): string | undefined { + return this._symbol + } + + // ==================== 转换方法 ==================== + + /** 转为原始值字符串 */ + toRawString(): string { + return this._raw.toString() + } + + /** 转为格式化显示(人类可读) */ + toFormatted(options?: { trimTrailingZeros?: boolean }): string { + const { trimTrailingZeros = true } = options ?? {} + + if (this._raw === 0n) return '0' + + const divisor = 10n ** BigInt(this._decimals) + const integerPart = this._raw / divisor + const fractionalPart = this._raw % divisor + + if (fractionalPart === 0n) { + return integerPart.toString() + } + + const fractionalStr = fractionalPart.toString().padStart(this._decimals, '0') + const trimmed = trimTrailingZeros ? fractionalStr.replace(/0+$/, '') : fractionalStr + + return `${integerPart}.${trimmed}` + } + + /** 转为 number(可能丢失精度,仅用于显示) */ + toNumber(): number { + return Number(this._raw) / 10 ** this._decimals + } + + /** 返回带符号的格式化字符串 */ + toString(): string { + const formatted = this.toFormatted() + return this._symbol ? `${formatted} ${this._symbol}` : formatted + } + + // ==================== 比较方法 ==================== + + /** 是否为零 */ + isZero(): boolean { + return this._raw === 0n + } + + /** 是否为正数 */ + isPositive(): boolean { + return this._raw > 0n + } + + /** 是否为负数 */ + isNegative(): boolean { + return this._raw < 0n + } + + /** 大于 */ + gt(other: Amount): boolean { + this.assertSameDecimals(other) + return this._raw > other._raw + } + + /** 大于等于 */ + gte(other: Amount): boolean { + this.assertSameDecimals(other) + return this._raw >= other._raw + } + + /** 小于 */ + lt(other: Amount): boolean { + this.assertSameDecimals(other) + return this._raw < other._raw + } + + /** 小于等于 */ + lte(other: Amount): boolean { + this.assertSameDecimals(other) + return this._raw <= other._raw + } + + /** 等于 */ + eq(other: Amount): boolean { + this.assertSameDecimals(other) + return this._raw === other._raw + } + + // ==================== 算术方法 ==================== + + /** 加法 */ + add(other: Amount): Amount { + this.assertSameDecimals(other) + return new Amount(this._raw + other._raw, this._decimals, this._symbol) + } + + /** 减法 */ + sub(other: Amount): Amount { + this.assertSameDecimals(other) + return new Amount(this._raw - other._raw, this._decimals, this._symbol) + } + + /** 乘法(用于计算百分比等) */ + mul(factor: number | bigint): Amount { + if (typeof factor === 'bigint') { + return new Amount(this._raw * factor, this._decimals, this._symbol) + } + // 对于 number,先放大再缩小以保持精度 + const scaleFactor = 1_000_000_000n // 9 位精度 + const scaled = BigInt(Math.round(factor * Number(scaleFactor))) + return new Amount((this._raw * scaled) / scaleFactor, this._decimals, this._symbol) + } + + /** 除法(向下取整) */ + div(divisor: number | bigint): Amount { + if (typeof divisor === 'bigint') { + return new Amount(this._raw / divisor, this._decimals, this._symbol) + } + const scaleFactor = 1_000_000_000n + const scaled = BigInt(Math.round(divisor * Number(scaleFactor))) + return new Amount((this._raw * scaleFactor) / scaled, this._decimals, this._symbol) + } + + /** 取最小值 */ + min(other: Amount): Amount { + this.assertSameDecimals(other) + return this._raw <= other._raw ? this : other + } + + /** 取最大值 */ + max(other: Amount): Amount { + this.assertSameDecimals(other) + return this._raw >= other._raw ? this : other + } + + // ==================== 工具方法 ==================== + + /** 创建具有相同精度和符号的新实例 */ + withRaw(raw: bigint): Amount { + return new Amount(raw, this._decimals, this._symbol) + } + + /** 创建具有不同符号的副本 */ + withSymbol(symbol: string): Amount { + return new Amount(this._raw, this._decimals, symbol) + } + + /** 验证精度是否相同 */ + private assertSameDecimals(other: Amount): void { + if (this._decimals !== other._decimals) { + throw new Error( + `Amount decimals mismatch: ${this._decimals} vs ${other._decimals}. ` + + `Cannot compare or operate on amounts with different decimals.` + ) + } + } + + // ==================== 私有辅助方法 ==================== + + /** 解析格式化字符串为 bigint */ + private static parseFormatted(value: string, decimals: number): bigint | null { + const input = value.trim() + if (!input) return null + + const parts = input.split('.') + if (parts.length > 2) return null + + const wholeRaw = parts[0] ?? '' + const fractionRaw = parts[1] ?? '' + const whole = wholeRaw === '' ? '0' : wholeRaw + + if (!/^[0-9]+$/.test(whole)) return null + if (fractionRaw && !/^[0-9]+$/.test(fractionRaw)) return null + if (fractionRaw.length > decimals) return null + + const fraction = fractionRaw.padEnd(decimals, '0') + const combined = `${whole}${fraction}`.replace(/^0+/, '') || '0' + + return BigInt(combined) + } +} + +// ==================== 辅助函数 ==================== + +/** + * 从 AssetInfo 创建 Amount + * 使用智能解析,自动判断 amount 是 raw 还是 formatted + */ +export function amountFromAsset(asset: { + amount: string + decimals: number + assetType?: string +}): Amount { + return Amount.parse(asset.amount, asset.decimals, asset.assetType) +}