diff --git a/package.json b/package.json index a4144277a..af3639711 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.140.0", "@tanstack/react-store": "^0.8.0", + "big.js": "^7.0.1", "bip39": "^3.1.0", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", @@ -100,6 +101,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/big.js": "^6.2.2", "@types/node": "^24.10.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69a28b941..e7aac53a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@tanstack/react-store': specifier: ^0.8.0 version: 0.8.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + big.js: + specifier: ^7.0.1 + version: 7.0.1 bip39: specifier: ^3.1.0 version: 3.1.0 @@ -165,6 +168,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/big.js': + specifier: ^6.2.2 + version: 6.2.2 '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -1977,6 +1983,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/big.js@6.2.2': + resolution: {integrity: sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2340,6 +2349,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big.js@7.0.1: + resolution: {integrity: sha512-iFgV784tD8kq4ccF1xtNMZnXeZzVuXWWM+ERFzKQjv+A5G9HC8CY3DuV45vgzFFcW+u2tIvmF95+AzWgs6BjCg==} + bip39@3.1.0: resolution: {integrity: sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==} @@ -6267,6 +6279,8 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/big.js@6.2.2': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6729,6 +6743,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + big.js@7.0.1: {} + bip39@3.1.0: dependencies: '@noble/hashes': 1.8.0 diff --git a/src/types/amount.test.ts b/src/types/amount.test.ts index 3560e045e..d8a539158 100644 --- a/src/types/amount.test.ts +++ b/src/types/amount.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { Amount, amountFromAsset } from './amount' +import { Amount, amountFromAsset, isAmount, toAmount } from './amount' describe('Amount', () => { describe('fromRaw', () => { @@ -14,6 +14,11 @@ describe('Amount', () => { const amount = Amount.fromRaw('1000000000000000000', 18) expect(amount.raw).toBe(1000000000000000000n) }) + + it('should create from number', () => { + const amount = Amount.fromRaw(1000000, 6) + expect(amount.raw).toBe(1000000n) + }) }) describe('fromFormatted', () => { @@ -32,6 +37,11 @@ describe('Amount', () => { expect(amount.raw).toBe(1000000000000000n) }) + it('should parse negative number', () => { + const amount = Amount.fromFormatted('-1.5', 18) + expect(amount.raw).toBe(-1500000000000000000n) + }) + it('should throw on invalid format', () => { expect(() => Amount.fromFormatted('abc', 18)).toThrow('Invalid amount format') }) @@ -51,6 +61,12 @@ describe('Amount', () => { it('should return null on invalid input', () => { expect(Amount.tryFromFormatted('abc', 18)).toBeNull() expect(Amount.tryFromFormatted('', 18)).toBeNull() + expect(Amount.tryFromFormatted('.', 18)).toBeNull() + expect(Amount.tryFromFormatted('1.2.3', 18)).toBeNull() + }) + + it('should return null when exceeding decimals', () => { + expect(Amount.tryFromFormatted('1.123456789', 8)).toBeNull() }) }) @@ -61,12 +77,11 @@ describe('Amount', () => { }) it('should parse formatted value (with decimal point)', () => { - const amount = Amount.parse('102531.02649070', 8) // 问题中的实际值 + 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) @@ -94,6 +109,23 @@ describe('Amount', () => { }) }) + describe('toDisplayString', () => { + it('should format with thousands separator', () => { + const amount = Amount.fromFormatted('1234567.89', 8) + expect(amount.toDisplayString({ locale: 'en-US' })).toBe('1,234,567.89') + }) + + it('should respect maximumFractionDigits', () => { + const amount = Amount.fromFormatted('1.23456789', 8) + expect(amount.toDisplayString({ maximumFractionDigits: 2 })).toBe('1.23') + }) + + it('should use different locale', () => { + const amount = Amount.fromFormatted('1234.56', 8) + expect(amount.toDisplayString({ locale: 'de-DE' })).toBe('1.234,56') + }) + }) + describe('comparison', () => { const a = Amount.fromFormatted('1.5', 18) const b = Amount.fromFormatted('2.0', 18) @@ -146,7 +178,7 @@ describe('Amount', () => { it('mul by integer', () => { const a = Amount.fromFormatted('1.5', 18) - expect(a.mul(2n).toFormatted()).toBe('3') + expect(a.mul(2).toFormatted()).toBe('3') }) it('mul by float', () => { @@ -156,7 +188,33 @@ describe('Amount', () => { it('div by integer', () => { const a = Amount.fromFormatted('10', 18) - expect(a.div(2n).toFormatted()).toBe('5') + expect(a.div(2).toFormatted()).toBe('5') + }) + + it('div rounds down', () => { + const a = Amount.fromFormatted('10', 18) + expect(a.div(3).toFormatted()).toBe('3.333333333333333333') + }) + + it('mod', () => { + // mod 操作在原始值上进行,10 raw % 3 = 1 raw + const a = Amount.fromRaw('10', 18) + expect(a.mod(3).toRawString()).toBe('1') + }) + + it('abs', () => { + const a = Amount.fromFormatted('-5', 18) + expect(a.abs().toFormatted()).toBe('5') + }) + + it('neg', () => { + const a = Amount.fromFormatted('5', 18) + expect(a.neg().toFormatted()).toBe('-5') + }) + + it('percent', () => { + const a = Amount.fromFormatted('100', 18) + expect(a.percent(5).toFormatted()).toBe('5') }) it('min/max', () => { @@ -167,6 +225,63 @@ describe('Amount', () => { }) }) + describe('decimals conversion', () => { + it('should convert to higher decimals', () => { + const amount = Amount.fromRaw('1000000', 6) // 1 USDC + const converted = amount.toDecimals(18) + expect(converted.decimals).toBe(18) + expect(converted.toFormatted()).toBe('1') + }) + + it('should convert to lower decimals', () => { + const amount = Amount.fromRaw('1000000000000000000', 18) // 1 ETH + const converted = amount.toDecimals(6) + expect(converted.decimals).toBe(6) + expect(converted.toFormatted()).toBe('1') + }) + + it('should preserve value during conversion', () => { + const amount = Amount.fromFormatted('123.456789', 8) + const converted = amount.toDecimals(18).toDecimals(8) + expect(converted.toFormatted()).toBe('123.456789') + }) + }) + + describe('serialization', () => { + it('toJSON', () => { + const amount = Amount.fromFormatted('1.5', 18, 'ETH') + const json = amount.toJSON() + expect(json).toEqual({ + raw: '1500000000000000000', + decimals: 18, + symbol: 'ETH', + }) + }) + + it('toJSON without symbol', () => { + const amount = Amount.fromFormatted('1.5', 18) + const json = amount.toJSON() + expect(json).toEqual({ + raw: '1500000000000000000', + decimals: 18, + }) + }) + + it('fromJSON', () => { + const json = { raw: '1500000000000000000', decimals: 18, symbol: 'ETH' } + const amount = Amount.fromJSON(json) + expect(amount.toFormatted()).toBe('1.5') + expect(amount.symbol).toBe('ETH') + }) + + it('roundtrip', () => { + const original = Amount.fromFormatted('123.456789', 18, 'TOKEN') + const restored = Amount.fromJSON(original.toJSON()) + expect(restored.eq(original)).toBe(true) + expect(restored.symbol).toBe(original.symbol) + }) + }) + describe('utility methods', () => { it('isZero', () => { expect(Amount.zero(18).isZero()).toBe(true) @@ -176,6 +291,13 @@ describe('Amount', () => { it('isPositive', () => { expect(Amount.fromFormatted('1', 18).isPositive()).toBe(true) expect(Amount.zero(18).isPositive()).toBe(false) + expect(Amount.fromFormatted('-1', 18).isPositive()).toBe(false) + }) + + it('isNegative', () => { + expect(Amount.fromFormatted('-1', 18).isNegative()).toBe(true) + expect(Amount.zero(18).isNegative()).toBe(false) + expect(Amount.fromFormatted('1', 18).isNegative()).toBe(false) }) it('toNumber', () => { @@ -192,6 +314,21 @@ describe('Amount', () => { const amount = Amount.fromFormatted('1.5', 18) expect(amount.withSymbol('ETH').symbol).toBe('ETH') }) + + it('withRaw', () => { + const amount = Amount.fromFormatted('1', 18, 'ETH') + const newAmount = amount.withRaw('2000000000000000000') + expect(newAmount.toFormatted()).toBe('2') + expect(newAmount.symbol).toBe('ETH') + }) + + it('withAsset', () => { + const amount = Amount.fromRaw('1000000', 6) + const newAmount = amount.withAsset({ decimals: 18, assetType: 'ETH' }) + expect(newAmount.decimals).toBe(18) + expect(newAmount.symbol).toBe('ETH') + expect(newAmount.toFormatted()).toBe('1') + }) }) describe('amountFromAsset', () => { @@ -208,4 +345,49 @@ describe('Amount', () => { expect(amount.toFormatted()).toBe('102531.0264907') }) }) + + describe('isAmount', () => { + it('should return true for Amount', () => { + expect(isAmount(Amount.zero(18))).toBe(true) + }) + + it('should return false for non-Amount', () => { + expect(isAmount('123')).toBe(false) + expect(isAmount(123)).toBe(false) + expect(isAmount(null)).toBe(false) + expect(isAmount({})).toBe(false) + }) + }) + + describe('toAmount', () => { + it('should return same instance if already Amount', () => { + const amount = Amount.fromFormatted('1', 18) + expect(toAmount(amount, 18)).toBe(amount) + }) + + it('should parse string', () => { + const amount = toAmount('1.5', 18, 'ETH') + expect(amount.toFormatted()).toBe('1.5') + expect(amount.symbol).toBe('ETH') + }) + }) + + describe('precision edge cases', () => { + it('should handle very large numbers', () => { + const amount = Amount.fromRaw('999999999999999999999999999999999999', 18) + expect(amount.toRawString()).toBe('999999999999999999999999999999999999') + }) + + it('should handle very small decimals', () => { + const amount = Amount.fromFormatted('0.000000000000000001', 18) + expect(amount.raw).toBe(1n) + }) + + it('should maintain precision in calculations', () => { + const a = Amount.fromFormatted('0.1', 18) + const b = Amount.fromFormatted('0.2', 18) + const sum = a.add(b) + expect(sum.toFormatted()).toBe('0.3') + }) + }) }) diff --git a/src/types/amount.ts b/src/types/amount.ts index 31d746fcb..048732413 100644 --- a/src/types/amount.ts +++ b/src/types/amount.ts @@ -1,8 +1,10 @@ +import Big from 'big.js' + /** * Amount - 不可变的金额类型 * * 设计原则: - * 1. 内部始终使用 bigint 存储原始值(最小单位) + * 1. 内部使用 Big.js 进行精确计算 * 2. 不可变 - 所有操作返回新实例 * 3. 类型安全 - 通过静态工厂方法创建,防止混淆 raw/formatted * 4. 自文档化 - 方法名清晰表达意图 @@ -26,12 +28,12 @@ * ``` */ export class Amount { - private readonly _raw: bigint + private readonly _value: Big // 存储原始单位的值 private readonly _decimals: number private readonly _symbol: string | undefined - private constructor(raw: bigint, decimals: number, symbol: string | undefined) { - this._raw = raw + private constructor(value: Big, decimals: number, symbol: string | undefined) { + this._value = value this._decimals = decimals this._symbol = symbol } @@ -42,8 +44,8 @@ export class Amount { * 从原始值创建(最小单位,如 wei、lamports、satoshi) * 用于:链上返回的余额、交易金额等 */ - static fromRaw(raw: bigint | string, decimals: number, symbol?: string): Amount { - const value = typeof raw === 'string' ? BigInt(raw) : raw + static fromRaw(raw: bigint | string | number, decimals: number, symbol?: string): Amount { + const value = new Big(raw.toString()) return new Amount(value, decimals, symbol) } @@ -53,11 +55,11 @@ export class Amount { * @throws 如果输入格式无效 */ static fromFormatted(formatted: string, decimals: number, symbol?: string): Amount { - const raw = Amount.parseFormatted(formatted, decimals) - if (raw === null) { + const result = Amount.tryFromFormatted(formatted, decimals, symbol) + if (result === null) { throw new Error(`Invalid amount format: "${formatted}"`) } - return new Amount(raw, decimals, symbol) + return result } /** @@ -65,16 +67,30 @@ export class Amount { * 用于:用户输入验证 */ 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) + const input = formatted.trim() + if (!input) return null + if (!/^-?\d*\.?\d*$/.test(input)) return null + if (input === '.' || input === '-' || input === '-.') return null + + try { + const parsed = new Big(input) + const multiplier = new Big(10).pow(decimals) + const raw = parsed.times(multiplier) + // 检查是否有超出精度的小数 + if (!raw.round(0, Big.roundDown).eq(raw)) { + return null // 超出精度 + } + return new Amount(raw.round(0, Big.roundDown), decimals, symbol) + } catch { + return null + } } /** * 创建零值 */ static zero(decimals: number, symbol?: string): Amount { - return new Amount(0n, decimals, symbol) + return new Amount(new Big(0), decimals, symbol) } /** @@ -89,11 +105,18 @@ export class Amount { return Amount.fromRaw(value, decimals, symbol) } + /** + * 从 JSON 反序列化 + */ + static fromJSON(json: AmountJSON): Amount { + return Amount.fromRaw(json.raw, json.decimals, json.symbol) + } + // ==================== Getters ==================== /** 原始值(bigint) */ get raw(): bigint { - return this._raw + return BigInt(this._value.toFixed(0)) } /** 精度(小数位数) */ @@ -110,32 +133,52 @@ export class Amount { /** 转为原始值字符串 */ toRawString(): string { - return this._raw.toString() + return this._value.toFixed(0) } /** 转为格式化显示(人类可读) */ toFormatted(options?: { trimTrailingZeros?: boolean }): string { const { trimTrailingZeros = true } = options ?? {} - if (this._raw === 0n) return '0' + if (this._value.eq(0)) return '0' - const divisor = 10n ** BigInt(this._decimals) - const integerPart = this._raw / divisor - const fractionalPart = this._raw % divisor + const divisor = new Big(10).pow(this._decimals) + const formatted = this._value.div(divisor) - if (fractionalPart === 0n) { - return integerPart.toString() + if (trimTrailingZeros) { + // 使用 Big.js 的 toFixed 然后去掉尾部零 + const fixed = formatted.toFixed(this._decimals) + return fixed.replace(/\.?0+$/, '') + } else { + return formatted.toFixed(this._decimals) } + } - const fractionalStr = fractionalPart.toString().padStart(this._decimals, '0') - const trimmed = trimTrailingZeros ? fractionalStr.replace(/0+$/, '') : fractionalStr - - return `${integerPart}.${trimmed}` + /** + * 转为带千分位的显示字符串 + */ + toDisplayString(options?: { + locale?: string + maximumFractionDigits?: number + minimumFractionDigits?: number + }): string { + const { + locale = 'en-US', + maximumFractionDigits = this._decimals, + minimumFractionDigits = 0, + } = options ?? {} + + const num = this.toNumber() + return new Intl.NumberFormat(locale, { + maximumFractionDigits, + minimumFractionDigits, + }).format(num) } /** 转为 number(可能丢失精度,仅用于显示) */ toNumber(): number { - return Number(this._raw) / 10 ** this._decimals + const divisor = new Big(10).pow(this._decimals) + return this._value.div(divisor).toNumber() } /** 返回带符号的格式化字符串 */ @@ -144,51 +187,60 @@ export class Amount { return this._symbol ? `${formatted} ${this._symbol}` : formatted } + /** 序列化为 JSON */ + toJSON(): AmountJSON { + return { + raw: this.toRawString(), + decimals: this._decimals, + ...(this._symbol !== undefined && { symbol: this._symbol }), + } + } + // ==================== 比较方法 ==================== /** 是否为零 */ isZero(): boolean { - return this._raw === 0n + return this._value.eq(0) } /** 是否为正数 */ isPositive(): boolean { - return this._raw > 0n + return this._value.gt(0) } /** 是否为负数 */ isNegative(): boolean { - return this._raw < 0n + return this._value.lt(0) } /** 大于 */ gt(other: Amount): boolean { this.assertSameDecimals(other) - return this._raw > other._raw + return this._value.gt(other._value) } /** 大于等于 */ gte(other: Amount): boolean { this.assertSameDecimals(other) - return this._raw >= other._raw + return this._value.gte(other._value) } /** 小于 */ lt(other: Amount): boolean { this.assertSameDecimals(other) - return this._raw < other._raw + return this._value.lt(other._value) } /** 小于等于 */ lte(other: Amount): boolean { this.assertSameDecimals(other) - return this._raw <= other._raw + return this._value.lte(other._value) } /** 等于 */ eq(other: Amount): boolean { this.assertSameDecimals(other) - return this._raw === other._raw + return this._value.eq(other._value) } // ==================== 算术方法 ==================== @@ -196,58 +248,104 @@ export class Amount { /** 加法 */ add(other: Amount): Amount { this.assertSameDecimals(other) - return new Amount(this._raw + other._raw, this._decimals, this._symbol) + return new Amount(this._value.plus(other._value), this._decimals, this._symbol) } /** 减法 */ sub(other: Amount): Amount { this.assertSameDecimals(other) - return new Amount(this._raw - other._raw, this._decimals, this._symbol) + return new Amount(this._value.minus(other._value), 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) + mul(factor: number | bigint | string | Big): Amount { + const bigFactor = factor instanceof Big ? factor : new Big(factor.toString()) + return new Amount(this._value.times(bigFactor).round(0, Big.roundDown), 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) + div(divisor: number | bigint | string | Big): Amount { + const bigDivisor = divisor instanceof Big ? divisor : new Big(divisor.toString()) + return new Amount(this._value.div(bigDivisor).round(0, Big.roundDown), this._decimals, this._symbol) + } + + /** 取余 */ + mod(divisor: number | bigint | string | Big): Amount { + const bigDivisor = divisor instanceof Big ? divisor : new Big(divisor.toString()) + return new Amount(this._value.mod(bigDivisor), this._decimals, this._symbol) + } + + /** 绝对值 */ + abs(): Amount { + return new Amount(this._value.abs(), this._decimals, this._symbol) + } + + /** 取反 */ + neg(): Amount { + return new Amount(this._value.neg(), this._decimals, this._symbol) } /** 取最小值 */ min(other: Amount): Amount { this.assertSameDecimals(other) - return this._raw <= other._raw ? this : other + return this._value.lte(other._value) ? this : other } /** 取最大值 */ max(other: Amount): Amount { this.assertSameDecimals(other) - return this._raw >= other._raw ? this : other + return this._value.gte(other._value) ? this : other + } + + /** + * 按百分比计算 + * @param percent 百分比值(如 5 表示 5%) + */ + percent(percent: number): Amount { + return this.mul(percent).div(100) + } + + // ==================== 精度转换 ==================== + + /** + * 转换到不同精度 + * @param newDecimals 新的精度 + */ + toDecimals(newDecimals: number): Amount { + if (newDecimals === this._decimals) return this + + const diff = newDecimals - this._decimals + if (diff > 0) { + // 增加精度,乘以 10^diff + const multiplier = new Big(10).pow(diff) + return new Amount(this._value.times(multiplier), newDecimals, this._symbol) + } else { + // 减少精度,除以 10^|diff|(向下取整) + const divisor = new Big(10).pow(-diff) + return new Amount(this._value.div(divisor).round(0, Big.roundDown), newDecimals, this._symbol) + } } // ==================== 工具方法 ==================== /** 创建具有相同精度和符号的新实例 */ - withRaw(raw: bigint): Amount { - return new Amount(raw, this._decimals, this._symbol) + withRaw(raw: bigint | string): Amount { + return Amount.fromRaw(raw, this._decimals, this._symbol) } /** 创建具有不同符号的副本 */ withSymbol(symbol: string): Amount { - return new Amount(this._raw, this._decimals, symbol) + return new Amount(this._value, this._decimals, symbol) + } + + /** 创建具有不同精度的副本(重新计算 raw 值) */ + withDecimals(decimals: number): Amount { + return this.toDecimals(decimals) + } + + /** 从资产信息创建新 Amount(继承 decimals 和 symbol) */ + withAsset(asset: { decimals: number; assetType?: string }): Amount { + return this.toDecimals(asset.decimals).withSymbol(asset.assetType ?? this._symbol ?? '') } /** 验证精度是否相同 */ @@ -259,30 +357,15 @@ export class Amount { ) } } +} - // ==================== 私有辅助方法 ==================== - - /** 解析格式化字符串为 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) - } +/** Amount 的 JSON 表示 */ +export interface AmountJSON { + raw: string + decimals: number + symbol?: string } // ==================== 辅助函数 ==================== @@ -298,3 +381,22 @@ export function amountFromAsset(asset: { }): Amount { return Amount.parse(asset.amount, asset.decimals, asset.assetType) } + +/** + * 类型守卫:检查是否为 Amount 实例 + */ +export function isAmount(value: unknown): value is Amount { + return value instanceof Amount +} + +/** + * 安全转换:如果已经是 Amount 则返回,否则解析 + */ +export function toAmount( + value: Amount | string, + decimals: number, + symbol?: string +): Amount { + if (isAmount(value)) return value + return Amount.parse(value, decimals, symbol) +}