From e2c83baac9b15ae1963b1f8729918e43075d6e8c Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 10 Oct 2026 02:00:36 +0400 Subject: [PATCH] InputNumber: reject decimals the unit cannot carry When an amount had more decimals than the selected unit allows, the function set result to -1 and then overwrote it, so the conversion went on with a negative exponent. BN computes 10 ** -n as 10 ** n: with 12 chain decimals, "1.1234567890" in milli became 13345678900 instead of 1123456789. Such input is now invalid. The decimals were also cut to the chain's decimals, which silently dropped digits of an asset with more decimals than the chain; the cut is removed, and with it the api argument it was the only use of. --- .../react-components/src/InputNumber.spec.tsx | 33 +++++++++++++++++++ packages/react-components/src/InputNumber.tsx | 25 +++++++------- 2 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 packages/react-components/src/InputNumber.spec.tsx diff --git a/packages/react-components/src/InputNumber.spec.tsx b/packages/react-components/src/InputNumber.spec.tsx new file mode 100644 index 0000000..2e1a10d --- /dev/null +++ b/packages/react-components/src/InputNumber.spec.tsx @@ -0,0 +1,33 @@ +// Copyright 2017-2026 @pezkuwi/react-components authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { formatBalance } from '@pezkuwi/util'; + +import { inputToBn } from './InputNumber.js'; + +function convert (input: string, unit: string, decimals: number): [string, boolean] { + const [bn, isValid] = inputToBn(input, formatBalance.findSi(unit), 128, false, true, undefined, decimals); + + return [bn.toString(), isValid]; +} + +describe('inputToBn', (): void => { + it('converts a decimal in the base unit', (): void => { + expect(convert('1.5', '-', 12)).toEqual(['1500000000000', true]); + }); + + it('converts every decimal a smaller unit can carry', (): void => { + expect(convert('1.123456789', 'm', 12)).toEqual(['1123456789', true]); + }); + + it('rejects more decimals than the unit can carry instead of scaling them up', (): void => { + // the exponent would be 9 - 10 = -1, and BN computes 10 ** -1 as 10 + expect(convert('1.1234567890', 'm', 12)).toEqual(['-1', false]); + }); + + it('keeps decimals beyond the chain decimals for an asset with more', (): void => { + expect(convert('1.0000000000001', '-', 18)).toEqual(['1000000000000100000', true]); + }); +}); diff --git a/packages/react-components/src/InputNumber.tsx b/packages/react-components/src/InputNumber.tsx index e7d9d32..7f6b6c8 100644 --- a/packages/react-components/src/InputNumber.tsx +++ b/packages/react-components/src/InputNumber.tsx @@ -1,13 +1,11 @@ // Copyright 2017-2026 @pezkuwi/react-components authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { ApiPromise } from '@pezkuwi/api'; import type { SiDef } from '@pezkuwi/util/types'; import type { BitLength } from './types.js'; import React, { useCallback, useEffect, useState } from 'react'; -import { useApi } from '@pezkuwi/react-hooks'; import { BN, BN_ONE, BN_TEN, BN_TWO, BN_ZERO, formatBalance, isBn, isUndefined } from '@pezkuwi/util'; import { TokenUnit } from './InputConsts/units.js'; @@ -94,7 +92,7 @@ function isValidNumber (bn: BN, bitLength: BitLength, isSigned: boolean, isZeroa return true; } -function inputToBn (api: ApiPromise, input: string, si: SiDef | null, bitLength: BitLength, isSigned: boolean, isZeroable: boolean, maxValue?: BN | null, decimals?: number): [BN, boolean] { +export function inputToBn (input: string, si: SiDef | null, bitLength: BitLength, isSigned: boolean, isZeroable: boolean, maxValue?: BN | null, decimals?: number): [BN, boolean] { const [siPower, basePower, siUnitPower] = getSiPowers(si, decimals); // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec @@ -102,12 +100,14 @@ function inputToBn (api: ApiPromise, input: string, si: SiDef | null, bitLength: let result; if (isDecimalValue) { + // More decimals than the unit can carry: the exponent below would be + // negative, and BN treats 10 ** -n as 10 ** n. if (siUnitPower - isDecimalValue[2].length < -basePower) { - result = new BN(-1); + return [new BN(-1), false]; } const div = new BN(input.replace(/\.\d*$/, '')); - const modString = input.replace(/^\d+\./, '').substring(0, api.registry.chainDecimals[0]); + const modString = input.replace(/^\d+\./, ''); const mod = new BN(modString); result = div @@ -125,10 +125,10 @@ function inputToBn (api: ApiPromise, input: string, si: SiDef | null, bitLength: ]; } -function getValuesFromString (api: ApiPromise, value: string, si: SiDef | null, bitLength: BitLength, isSigned: boolean, isZeroable: boolean, maxValue?: BN | null, decimals?: number): [string, BN, boolean] { +function getValuesFromString (value: string, si: SiDef | null, bitLength: BitLength, isSigned: boolean, isZeroable: boolean, maxValue?: BN | null, decimals?: number): [string, BN, boolean] { return [ value, - ...inputToBn(api, value, si, bitLength, isSigned, isZeroable, maxValue, decimals) + ...inputToBn(value, si, bitLength, isSigned, isZeroable, maxValue, decimals) ]; } @@ -148,22 +148,21 @@ function getValuesFromBn (valueBn: BN, si: SiDef | null, isSigned: boolean, isZe ]; } -function getValues (api: ApiPromise, value: BN | string = BN_ZERO, si: SiDef | null, bitLength: BitLength, isSigned: boolean, isZeroable: boolean, maxValue?: BN | null, decimals?: number): [string, BN, boolean] { +function getValues (value: BN | string = BN_ZERO, si: SiDef | null, bitLength: BitLength, isSigned: boolean, isZeroable: boolean, maxValue?: BN | null, decimals?: number): [string, BN, boolean] { return isBn(value) ? getValuesFromBn(value, si, isSigned, isZeroable, decimals) - : getValuesFromString(api, value, si, bitLength, isSigned, isZeroable, maxValue, decimals); + : getValuesFromString(value, si, bitLength, isSigned, isZeroable, maxValue, decimals); } function InputNumber ({ autoFocus, bitLength = DEFAULT_BITLENGTH, children, className = '', defaultValue, isDecimal, isDisabled, isError = false, isFull, isLoading, isSi, isSigned = false, isWarning, isZeroable = true, label, labelExtra, maxLength, maxValue, onChange, onEnter, onEscape, placeholder, siDecimals, siDefault, siSymbol, value: propsValue }: Props): React.ReactElement { const { t } = useTranslation(); - const { api } = useApi(); const [si] = useState(() => isSi ? siDefault || formatBalance.findSi('-') : null ); const [[value, valueBn, isValid], setValues] = useState<[string, BN, boolean]>(() => - getValues(api, propsValue || defaultValue, si, bitLength, isSigned, isZeroable, maxValue, siDecimals) + getValues(propsValue || defaultValue, si, bitLength, isSigned, isZeroable, maxValue, siDecimals) ); const [isPreKeyDown, setIsPreKeyDown] = useState(false); @@ -173,9 +172,9 @@ function InputNumber ({ autoFocus, bitLength = DEFAULT_BITLENGTH, children, clas const _onChangeWithSi = useCallback( (input: string, si: SiDef | null) => setValues( - getValuesFromString(api, input, si, bitLength, isSigned, isZeroable, maxValue, siDecimals) + getValuesFromString(input, si, bitLength, isSigned, isZeroable, maxValue, siDecimals) ), - [api, bitLength, isSigned, isZeroable, maxValue, siDecimals] + [bitLength, isSigned, isZeroable, maxValue, siDecimals] ); const _onChange = useCallback(