Skip to content

Commit

Permalink
fix: formatDecimals precision (#912)
Browse files Browse the repository at this point in the history
  • Loading branch information
0xAlec authored Jul 29, 2024
1 parent 33d919a commit 5d9b4f8
Show file tree
Hide file tree
Showing 13 changed files with 311 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-pianos-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/onchainkit": patch
---

**fix**: formatDecimals precision by @0xAlec #912
18 changes: 18 additions & 0 deletions src/swap/utils/buildSwapTransaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,4 +413,22 @@ describe('buildSwapTransaction', () => {
mockApiParams,
]);
});

it('should return an error object from buildSwapTransaction for invalid `amount` input', async () => {
const mockParams = {
useAggregator: true,
fromAddress: testFromAddress as `0x${string}`,
amountReference: testAmountReference,
from: ETH,
to: DEGEN,
amount: 'invalid',
isAmountInDecimals: false,
};

const error = await buildSwapTransaction(mockParams);
expect(error).toEqual({
code: 'INVALID_INPUT',
error: 'Invalid input: amount must be a non-negative number string',
});
});
});
9 changes: 8 additions & 1 deletion src/swap/utils/buildSwapTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ export async function buildSwapTransaction(
isAmountInDecimals: false,
};

let apiParams = getAPIParamsForToken({ ...defaultParams, ...params });
const apiParamsOrError = getAPIParamsForToken({
...defaultParams,
...params,
});
if ((apiParamsOrError as SwapError).error) {
return apiParamsOrError as SwapError;
}
let apiParams = apiParamsOrError as SwapAPIParams;

if (!params.useAggregator) {
apiParams = {
Expand Down
66 changes: 18 additions & 48 deletions src/swap/utils/formatDecimals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,33 @@ import { describe, expect, it } from 'vitest';
import { formatDecimals } from './formatDecimals';

describe('formatDecimals', () => {
it('should format the amount correctly with default decimals when inputInDecimals is true', () => {
const amount = '1500000000000000000';
const expectedFormattedAmount = '1.5';
const result = formatDecimals(amount, true, 18);
expect(result).toEqual(expectedFormattedAmount);
it('formats input in decimals correctly', () => {
expect(formatDecimals('1000000000000000000', true, 18)).toBe('1');
expect(formatDecimals('1500000', true, 6)).toBe('1.5');
});

it('should format the amount correctly with custom decimals when inputInDecimals is true', () => {
const amount = '1500000000000000000';
const decimals = 9;
const expectedFormattedAmount = '1500000000';
const result = formatDecimals(amount, true, decimals);
expect(result).toEqual(expectedFormattedAmount);
it('formats input not in decimals correctly', () => {
expect(formatDecimals('1', false, 18)).toBe('1000000000000000000');
expect(formatDecimals('1.5', false, 6)).toBe('1500000');
});

it('should format the amount correctly with default decimals when inputInDecimals is false', () => {
const amount = '1.5';
const expectedFormattedAmount = '1500000000000000000';
const result = formatDecimals(amount, false, 18);
expect(result).toEqual(expectedFormattedAmount);
it('uses default 18 decimals when not specified', () => {
expect(formatDecimals('1000000000000000000', true)).toBe('1');
expect(formatDecimals('1', false)).toBe('1000000000000000000');
});

it('should format the amount correctly with custom decimals when inputInDecimals is false', () => {
const amount = '1.5';
const decimals = 9;
const expectedFormattedAmount = '1500000000';
const result = formatDecimals(amount, false, decimals);
expect(result).toEqual(expectedFormattedAmount);
it('handles very small and very large numbers', () => {
expect(formatDecimals('1', true, 18)).toBe('0.000000000000000001');
expect(formatDecimals('0.000000000000000001', false, 18)).toBe('1');
});

it('should format the amount correctly with default decimals when inputInDecimals is true and decimals is not provided', () => {
const amount = '1500000000000000000';
const expectedFormattedAmount = '1.5';
const result = formatDecimals(amount);
expect(result).toEqual(expectedFormattedAmount);
it('handles zero decimal places', () => {
expect(formatDecimals('1', true, 0)).toBe('1');
expect(formatDecimals('1', false, 0)).toBe('1');
});

it('should format the amount correctly with default decimals when inputInDecimals is true and decimals is provided', () => {
const amount = '1500000000';
const expectedFormattedAmount = '1.5';
const decimals = 9;
const result = formatDecimals(amount, true, decimals);
expect(result).toEqual(expectedFormattedAmount);
});

it('should format the amount correctly with default decimals when inputInDecimals is false and decimals is provided', () => {
const amount = '1.5';
const expectedFormattedAmount = '1500000000';
const decimals = 9;
const result = formatDecimals(amount, false, decimals);
expect(result).toEqual(expectedFormattedAmount);
});

it('should format the amount correctly with default decimals when inputInDecimals is false and decimals is not provided', () => {
const amount = '1.5';
const expectedFormattedAmount = '1.5e-18';
const result = formatDecimals(amount);
expect(result).toEqual(expectedFormattedAmount);
it('handles large number of decimal places', () => {
expect(formatDecimals('1', true, 100)).toBe(`0.${'0'.repeat(99)}1`);
expect(formatDecimals(`0.${'0'.repeat(99)}1`, false, 100)).toBe('1');
});
});
14 changes: 12 additions & 2 deletions src/swap/utils/formatDecimals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { fromReadableAmount } from './fromReadableAmount';
import { toReadableAmount } from './toReadableAmount';

/**
* Formats an amount according to the decimals. Defaults to 18 decimals for ERC-20s.
*/
Expand All @@ -6,8 +9,15 @@ export function formatDecimals(
inputInDecimals = true,
decimals = 18,
): string {
let result: string;

if (inputInDecimals) {
return (Number(amount) / 10 ** decimals).toString();
// If input is already in decimals, convert to readable amount
result = toReadableAmount(amount, decimals);
} else {
// If input is not in decimals, convert from readable amount
result = fromReadableAmount(amount, decimals);
}
return (Number(amount) * 10 ** decimals).toString();

return result;
}
38 changes: 38 additions & 0 deletions src/swap/utils/fromReadableAmount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
/**
* @vitest-environment node
*/
import { fromReadableAmount } from './fromReadableAmount';

describe('fromReadableAmount', () => {
it('converts whole numbers correctly', () => {
expect(fromReadableAmount('100', 18)).toBe('100000000000000000000');
expect(fromReadableAmount('1', 6)).toBe('1000000');
});

it('handles decimals correctly', () => {
expect(fromReadableAmount('1.5', 18)).toBe('1500000000000000000');
expect(fromReadableAmount('0.1', 6)).toBe('100000');
});

it('handles very small numbers', () => {
expect(fromReadableAmount('0.000000000000000001', 18)).toBe('1');
expect(fromReadableAmount('0.000001', 6)).toBe('1');
});

it('handles numbers with fewer digits than decimals', () => {
expect(fromReadableAmount('0.1', 18)).toBe('100000000000000000');
expect(fromReadableAmount('0.000001', 18)).toBe('1000000000000');
});

it('handles zero correctly', () => {
expect(fromReadableAmount('0', 18)).toBe('0');
expect(fromReadableAmount('0.0', 18)).toBe('0');
});

it('handles large numbers correctly', () => {
expect(fromReadableAmount('1000000000000000000', 18)).toBe(
'1000000000000000000000000000000000000',
);
});
});
9 changes: 9 additions & 0 deletions src/swap/utils/fromReadableAmount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function fromReadableAmount(amount: string, decimals: number): string {
const [wholePart, fractionalPart = ''] = amount.split('.');
const paddedFractionalPart = fractionalPart.padEnd(decimals, '0');
const trimmedFractionalPart = paddedFractionalPart.slice(0, decimals);
return (
BigInt(wholePart + trimmedFractionalPart) *
10n ** BigInt(decimals - trimmedFractionalPart.length)
).toString();
}
106 changes: 106 additions & 0 deletions src/swap/utils/getAPIParamsForToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('getAPIParamsForToken', () => {
};

const result = getAPIParamsForToken({
useAggregator: true,
from,
to,
amount,
Expand Down Expand Up @@ -74,6 +75,7 @@ describe('getAPIParamsForToken', () => {
};

const result = getAPIParamsForToken({
useAggregator: true,
from,
to,
amount,
Expand Down Expand Up @@ -111,6 +113,7 @@ describe('getAPIParamsForToken', () => {
amountReference: 'from',
};
const result = getAPIParamsForToken({
useAggregator: true,
from,
to,
amount,
Expand Down Expand Up @@ -148,11 +151,114 @@ describe('getAPIParamsForToken', () => {
amountReference: 'from',
};
const result = getAPIParamsForToken({
useAggregator: true,
from,
to,
amount,
amountReference,
});
expect(result).toEqual(expectedParams);
});

it('should return an error if amount is negative', () => {
const to: Token = {
name: 'ETH',
address: '',
symbol: 'ETH',
decimals: 18,
image:
'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png',
chainId: 8453,
};
const from: Token = {
name: 'DEGEN',
address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed',
symbol: 'DEGEN',
decimals: 18,
image:
'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm',
chainId: 8453,
};
const amount = '-1';
const amountReference = 'from';
const result = getAPIParamsForToken({
useAggregator: true,
from,
to,
amount,
amountReference,
});
expect(result).toEqual({
code: 'INVALID_INPUT',
error: 'Invalid input: amount must be a non-negative number string',
});
});

it('should return an error if amount is empty', () => {
const to: Token = {
name: 'ETH',
address: '',
symbol: 'ETH',
decimals: 18,
image:
'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png',
chainId: 8453,
};
const from: Token = {
name: 'DEGEN',
address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed',
symbol: 'DEGEN',
decimals: 18,
image:
'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm',
chainId: 8453,
};
const amount = '';
const amountReference = 'from';
const result = getAPIParamsForToken({
useAggregator: true,
from,
to,
amount,
amountReference,
});
expect(result).toEqual({
code: 'INVALID_INPUT',
error: 'Invalid input: amount must be a non-empty string',
});
});

it('should return an error if decimals is not an integer', () => {
const to: Token = {
name: 'ETH',
address: '',
symbol: 'ETH',
decimals: 18,
image:
'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png',
chainId: 8453,
};
const from: Token = {
name: 'DEGEN',
address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed',
symbol: 'DEGEN',
decimals: 1.1,
image:
'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm',
chainId: 8453,
};
const amount = '1';
const amountReference = 'from';
const result = getAPIParamsForToken({
useAggregator: true,
from,
to,
amount,
amountReference,
});
expect(result).toEqual({
code: 'INVALID_INPUT',
error: 'Invalid input: decimals must be a non-negative integer',
});
});
});
24 changes: 23 additions & 1 deletion src/swap/utils/getAPIParamsForToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
BuildSwapTransactionParams,
GetAPIParamsForToken,
SwapAPIParams,
SwapError,
} from '../types';
import { formatDecimals } from './formatDecimals';

Expand All @@ -12,10 +13,31 @@ import { formatDecimals } from './formatDecimals';
*/
export function getAPIParamsForToken(
params: GetAPIParamsForToken,
): SwapAPIParams {
): SwapAPIParams | SwapError {
const { from, to, amount, amountReference, isAmountInDecimals } = params;
const { fromAddress } = params as BuildSwapTransactionParams;
const decimals = amountReference === 'from' ? from.decimals : to.decimals;

// Input validation
if (typeof amount !== 'string' || amount.trim() === '') {
return {
code: 'INVALID_INPUT',
error: 'Invalid input: amount must be a non-empty string',
};
}
if (!Number.isInteger(decimals) || decimals < 0) {
return {
code: 'INVALID_INPUT',
error: 'Invalid input: decimals must be a non-negative integer',
};
}
if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(amount)) {
return {
code: 'INVALID_INPUT',
error: 'Invalid input: amount must be a non-negative number string',
};
}

return {
fromAddress: fromAddress,
from: from.address || 'ETH',
Expand Down
Loading

0 comments on commit 5d9b4f8

Please sign in to comment.