Skip to content

Commit d886920

Browse files
authored
Merge pull request #122 from hyperweb-io/demo-convert
denomination improvement
2 parents 62f7838 + a3a1e8f commit d886920

File tree

2 files changed

+200
-108
lines changed

2 files changed

+200
-108
lines changed
+115-76
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,135 @@
11
/**
2-
* Ethereum denominations and conversion utilities
2+
* Utility functions for handling Ethereum denominations
3+
* Provides conversion between ETH/tokens and their smallest units (wei/base units)
34
*/
45

5-
// Denomination constants with their values in wei
6-
export const DENOMINATIONS = {
7-
wei: '1',
8-
kwei: '1000',
9-
mwei: '1000000',
10-
gwei: '1000000000',
11-
microether: '1000000000000',
12-
milliether: '1000000000000000',
13-
ether: '1000000000000000000',
14-
} as const;
6+
// Constants
7+
const ETHER_DECIMALS = 18;
158

16-
export type EthDenomination = keyof typeof DENOMINATIONS;
17-
18-
// Unit type can be either a predefined denomination or a number representing decimal places
19-
export type UnitType = EthDenomination | number;
9+
/**
10+
* Converts a string or number representing ETH to wei (the smallest unit)
11+
* @param etherStr - The ETH amount as a string or number
12+
* @returns The amount in wei as BigInt
13+
*/
14+
export function parseEther(etherStr: string | number): bigint {
15+
return parseUnits(etherStr, ETHER_DECIMALS);
16+
}
2017

2118
/**
22-
* Gets the wei value for a given unit
23-
* @param unit - The unit (either a predefined denomination or a decimal precision number)
24-
* @returns The wei value as BigInt
19+
* Converts wei (the smallest unit) to a string representing ETH
20+
* @param wei - The amount in wei as BigInt or string
21+
* @returns The amount in ETH as a decimal string
2522
*/
26-
function getWeiValue(unit: UnitType): bigint {
27-
if (typeof unit === 'number') {
28-
return BigInt(10) ** BigInt(unit);
29-
}
30-
return BigInt(DENOMINATIONS[unit]);
23+
export function formatEther(wei: bigint | string): string {
24+
return formatUnits(wei, ETHER_DECIMALS);
3125
}
3226

3327
/**
34-
* Converts from one unit to another
35-
* @param amount - The amount to convert
36-
* @param fromUnit - The source unit (denomination name or decimal precision)
37-
* @param toUnit - The target unit (denomination name or decimal precision)
38-
* @returns The converted amount as string
28+
* Converts a human-readable token amount to its smallest unit
29+
* @param amount - The token amount as a string or number
30+
* @param decimals - The number of decimals the token uses
31+
* @returns The amount in the smallest unit as BigInt
3932
*/
40-
export function convert(
41-
amount: string | number,
42-
fromUnit: UnitType,
43-
toUnit: UnitType
44-
): string {
45-
const amountStr = typeof amount === 'string' ? amount : amount.toString();
46-
47-
// Get wei values for each unit
48-
const fromWei = getWeiValue(fromUnit);
49-
const toWei = getWeiValue(toUnit);
50-
51-
// Handle decimal points in the input
52-
let [whole, decimal] = amountStr.split('.');
53-
whole = whole || '0';
54-
decimal = decimal || '';
55-
56-
// Calculate base-10 decimals in fromUnit
57-
const fromDecimals = typeof fromUnit === 'number'
58-
? fromUnit
59-
: DENOMINATIONS[fromUnit].length - 1;
60-
61-
// Convert to BigInt with proper decimal handling
62-
let valueInSmallestUnit;
63-
if (decimal) {
64-
// Scale the decimal part properly
65-
const scaledDecimal = decimal.padEnd(Number(fromDecimals), '0').slice(0, Number(fromDecimals));
66-
// Convert decimal part to equivalent wei value
67-
const decimalValue = BigInt(scaledDecimal) * BigInt(10) ** BigInt(Math.max(0, Number(fromDecimals) - decimal.length));
68-
valueInSmallestUnit = BigInt(whole) * fromWei + decimalValue;
33+
export function parseUnits(amount: string | number, decimals: number): bigint {
34+
// Validate inputs
35+
if (decimals < 0 || !Number.isInteger(decimals)) {
36+
throw new Error('Decimals must be a non-negative integer');
37+
}
38+
39+
// Convert amount to string
40+
const amountStr = amount.toString();
41+
42+
// Handle scientific notation if present
43+
if (amountStr.includes('e')) {
44+
// Parse scientific notation directly without recursion
45+
const [mantissa, exponentStr] = amountStr.split('e');
46+
const exponent = parseInt(exponentStr);
47+
48+
// Handle the mantissa with its own decimal places
49+
let [intPart, fracPart = ''] = mantissa.split('.');
50+
51+
if (exponent >= 0) {
52+
// Positive exponent - shift decimal point to the right
53+
if (fracPart.length <= exponent) {
54+
// Pad with zeros as needed
55+
const result = intPart + fracPart.padEnd(exponent, '0');
56+
return parseUnits(result, decimals);
57+
} else {
58+
// Move decimal point within the fraction
59+
const result = intPart + fracPart.substring(0, exponent) + '.' + fracPart.substring(exponent);
60+
return parseUnits(result, decimals);
61+
}
62+
} else {
63+
// Negative exponent - shift decimal point to the left
64+
const absExponent = Math.abs(exponent);
65+
if (intPart.length <= absExponent) {
66+
// Result is < 1
67+
const padding = '0'.repeat(absExponent - intPart.length);
68+
const result = '0.' + padding + intPart + fracPart;
69+
return parseUnits(result, decimals);
70+
} else {
71+
// Insert decimal point in the integer part
72+
const splitPoint = intPart.length - absExponent;
73+
const result = intPart.substring(0, splitPoint) + '.' + intPart.substring(splitPoint) + fracPart;
74+
return parseUnits(result, decimals);
75+
}
76+
}
77+
}
78+
79+
// Split the decimal string
80+
let [integerPart, fractionPart = ''] = amountStr.split('.');
81+
82+
// Remove leading zeros from the integer part (but keep at least one digit)
83+
integerPart = integerPart.replace(/^0+(?=\d)/, '');
84+
if (integerPart === '') integerPart = '0';
85+
86+
// Pad or truncate the fraction part according to decimals
87+
if (fractionPart.length > decimals) {
88+
fractionPart = fractionPart.slice(0, decimals);
6989
} else {
70-
valueInSmallestUnit = BigInt(whole) * fromWei;
90+
fractionPart = fractionPart.padEnd(decimals, '0');
7191
}
7292

73-
// Convert to target unit using uniform division
74-
const result = valueInSmallestUnit / toWei;
75-
return result.toString();
76-
}
93+
// Combine integer and fraction parts without decimal point
94+
const result = integerPart + fractionPart;
7795

78-
/**
79-
* Converts from ETH to any unit
80-
* @param amount - The amount in ETH
81-
* @param toUnit - The target unit (denomination name or decimal precision)
82-
* @returns The converted amount as string
83-
*/
84-
export function ethToUnit(amount: string | number, toUnit: UnitType): string {
85-
return convert(amount, 'ether', toUnit);
96+
// Convert to BigInt - remove leading zeros to prevent octal interpretation
97+
return BigInt(result.replace(/^0+(?=\d)/, '') || '0');
8698
}
8799

88100
/**
89-
* Converts from any unit to ETH
90-
* @param amount - The amount in the source unit
91-
* @param fromUnit - The source unit (denomination name or decimal precision)
92-
* @returns The amount in ETH as string
101+
* Converts an amount in smallest units to a human-readable token amount
102+
* @param amount - The amount in the smallest units as BigInt or string
103+
* @param decimals - The number of decimals the token uses
104+
* @returns The human-readable token amount as a decimal string
93105
*/
94-
export function unitToEth(amount: string | number, fromUnit: UnitType): string {
95-
return convert(amount, fromUnit, 'ether');
106+
export function formatUnits(amount: bigint | string, decimals: number): string {
107+
// Validate inputs
108+
if (decimals < 0 || !Number.isInteger(decimals)) {
109+
throw new Error('Decimals must be a non-negative integer');
110+
}
111+
112+
// Convert amount to string
113+
const amountStr = amount.toString();
114+
115+
// If amount is 0, return "0"
116+
if (amountStr === '0') return '0';
117+
118+
// Pad with leading zeros to ensure we have at least 'decimals' digits
119+
const padded = amountStr.padStart(decimals, '0');
120+
121+
// Calculate the integer part and fractional part positions
122+
const integerPartLength = Math.max(0, padded.length - decimals);
123+
124+
// Extract the integer and fraction parts
125+
const integerPart = integerPartLength > 0 ? padded.slice(0, integerPartLength) : '0';
126+
const fractionPart = padded.slice(integerPartLength);
127+
128+
// Trim trailing zeros from fraction part
129+
const trimmedFraction = fractionPart.replace(/0+$/, '');
130+
131+
// Combine the parts with a decimal point if necessary
132+
return trimmedFraction.length > 0
133+
? `${integerPart}.${trimmedFraction}`
134+
: integerPart;
96135
}

networks/ethereum/starship/__tests__/utils.test.ts

+85-32
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isValidEthereumAddress, toChecksumAddress } from '../../src/utils/address';
2-
import { convert, ethToUnit, unitToEth, DENOMINATIONS } from '../../src/utils/denominations';
2+
import { parseEther, formatEther, parseUnits, formatUnits } from '../../src/utils/denominations';
33
import { utf8ToHex, hexToUtf8 } from '../../src/utils/encoding';
44

55
describe('address utils', () => {
@@ -34,54 +34,107 @@ describe('address utils', () => {
3434
});
3535
});
3636

37+
describe('parseEther', () => {
38+
test('should convert ETH to wei', () => {
39+
expect(parseEther('1.0').toString()).toBe('1000000000000000000');
40+
expect(parseEther('2.5').toString()).toBe('2500000000000000000');
41+
expect(parseEther('0.000000000000000001').toString()).toBe('1');
42+
});
43+
44+
test('should handle zero', () => {
45+
expect(parseEther('0').toString()).toBe('0');
46+
expect(parseEther('0.0').toString()).toBe('0');
47+
});
48+
49+
test('should handle string and number inputs', () => {
50+
expect(parseEther('5').toString()).toBe(parseEther(5).toString());
51+
});
52+
53+
test('should handle scientific notation', () => {
54+
expect(parseEther('1e18').toString()).toBe('1000000000000000000000000000000000000');
55+
expect(parseEther('2e-18').toString()).toBe('2');
56+
});
57+
58+
test('should truncate excess decimals beyond 18 places', () => {
59+
// Should truncate, not round
60+
expect(parseEther('1.0000000000000000005').toString()).toBe('1000000000000000000');
61+
expect(parseEther('0.0000000000000000001').toString()).toBe('0');
62+
});
63+
});
64+
65+
describe('formatEther', () => {
66+
test('should convert wei to ETH', () => {
67+
expect(formatEther(BigInt('1000000000000000000'))).toBe('1');
68+
expect(formatEther('1000000000000000000')).toBe('1');
69+
expect(formatEther(BigInt('2500000000000000000'))).toBe('2.5');
70+
});
71+
72+
test('should handle zero', () => {
73+
expect(formatEther(BigInt('0'))).toBe('0');
74+
expect(formatEther('0')).toBe('0');
75+
});
3776

38-
describe('denominations utils', () => {
39-
test('DENOMINATIONS constants', () => {
40-
expect(DENOMINATIONS.wei).toBe('1');
41-
expect(DENOMINATIONS.ether).toBe('1000000000000000000');
77+
test('should format with correct precision', () => {
78+
expect(formatEther(BigInt('1'))).toBe('0.000000000000000001');
79+
expect(formatEther(BigInt('100000000'))).toBe('0.0000000001');
4280
});
4381

44-
test('convert wei to ether', () => {
45-
expect(convert('1000000000000000000', 'wei', 'ether')).toBe('1');
82+
test('should remove trailing zeros', () => {
83+
expect(formatEther(BigInt('1000000000000000000'))).toBe('1'); // Not '1.000000000000000000'
84+
expect(formatEther(BigInt('1100000000000000000'))).toBe('1.1'); // Not '1.100000000000000000'
85+
});
86+
});
87+
88+
describe('parseUnits', () => {
89+
test('should convert amount to smallest units with custom decimals', () => {
90+
expect(parseUnits('1.0', 6).toString()).toBe('1000000'); // 6 decimals (USDC)
91+
expect(parseUnits('2.5', 8).toString()).toBe('250000000'); // 8 decimals (WBTC)
92+
expect(parseUnits('1', 0).toString()).toBe('1'); // 0 decimals
4693
});
4794

48-
test('convert gwei to ether', () => {
49-
expect(convert('1000000000', 'gwei', 'ether')).toBe('1');
95+
test('should handle more decimal places than specified', () => {
96+
expect(parseUnits('1.123456789', 6).toString()).toBe('1123456'); // Truncate to 6 decimals
97+
expect(parseUnits('0.1234567890123456789', 10).toString()).toBe('1234567890'); // Truncate to 10 decimals
5098
});
5199

52-
test('convert ether to ether', () => {
53-
expect(convert('2', 'ether', 'ether')).toBe('2');
100+
test('should handle leading and trailing zeros', () => {
101+
expect(parseUnits('01.100', 6).toString()).toBe('1100000');
102+
expect(parseUnits('000.0010', 6).toString()).toBe('1000');
54103
});
55104

56-
test('ethToUnit and unitToEth coherence', () => {
57-
const amountEth = '3';
58-
const amountWei = DENOMINATIONS.ether;
59-
expect(ethToUnit(amountEth, 'wei')).toBe(convert(amountEth, 'ether', 'wei'));
60-
expect(unitToEth(amountWei, 'wei')).toBe(convert(amountWei, 'wei', 'ether'));
105+
test('should throw for negative decimals', () => {
106+
expect(() => parseUnits('1.0', -1)).toThrow('Decimals must be a non-negative integer');
107+
});
108+
109+
test('should handle very large values', () => {
110+
expect(parseUnits('1000000000000000000', 18).toString()).toBe('1000000000000000000000000000000000000');
111+
});
112+
});
113+
114+
describe('formatUnits', () => {
115+
test('should convert smallest units to human-readable form with custom decimals', () => {
116+
expect(formatUnits(BigInt('1000000'), 6)).toBe('1');
117+
expect(formatUnits(BigInt('250000000'), 8)).toBe('2.5');
118+
expect(formatUnits(BigInt('1'), 0)).toBe('1');
61119
});
62120

63-
test('convert numeric unit (11) to wei and back', () => {
64-
// 1 * 10^11 = 100000000000 wei
65-
expect(convert('1', 11, 'wei')).toBe('100000000000');
66-
// 100000000000 wei / 10^11 = 1
67-
expect(convert('100000000000', 'wei', 11)).toBe('1');
121+
test('should handle string inputs', () => {
122+
expect(formatUnits('1000000', 6)).toBe('1');
123+
expect(formatUnits('250000000', 8)).toBe('2.5');
68124
});
69125

70-
test('convert using numeric unit (5) precision', () => {
71-
// 1.23456 * 10^5 = 123456
72-
expect(convert('1.23456', 5, 'wei')).toBe('123456');
73-
// 123456 wei / 10^5 = 1
74-
expect(convert('123456', 'wei', 5)).toBe('1');
126+
test('should handle zero values', () => {
127+
expect(formatUnits(BigInt('0'), 18)).toBe('0');
128+
expect(formatUnits('0', 6)).toBe('0');
75129
});
76130

77-
test('truncate decimals beyond 18 places for ether to wei', () => {
78-
// Only first 18 decimal digits are used
79-
expect(convert('0.01234567890123456789', 'ether', 'wei')).toBe('12345678901234567');
131+
test('should handle small values', () => {
132+
expect(formatUnits(BigInt('1'), 18)).toBe('0.000000000000000001');
133+
expect(formatUnits(BigInt('10'), 18)).toBe('0.00000000000000001');
80134
});
81135

82-
test('convert truncated wei back to ether (floor)', () => {
83-
// Truncated wei divided by 1e18 yields 0 (integer division)
84-
expect(convert('12345678901234567', 'wei', 'ether')).toBe('0');
136+
test('should throw for negative decimals', () => {
137+
expect(() => formatUnits(BigInt('1'), -1)).toThrow('Decimals must be a non-negative integer');
85138
});
86139
});
87140

0 commit comments

Comments
 (0)