Skip to content

Commit

Permalink
Handle bigints in the TokenAmountInput (#699)
Browse files Browse the repository at this point in the history
Resolves #694

### Problem

We have been using floating point numbers to transform bigints to
strings with 18 decimal places. But it was easy to mess up the precision
because bigints were larger than max safe int. We need a way to be able
to work in the inputs with both very large and very small numbers.

### What has been done
Make input component handle value changes with bigints not strings.
Adjust components using the `TokenAmountInput` to new format.

- show nicely small balances (`<0,0001 TAHO`) -
`bigIntToDisplayUserAmount` should be able to display nice label for
values smaller than desired precision, let's make it to show the
smallest possible number like: `<0.01` for desired precision `2` etc.
- Allow using "Max" button and don't allow the input to override the
real value with stringified value with less precision.
- Add util to display bigints as strings with max precision


![image](https://github.com/tahowallet/dapp/assets/20949277/d9dc904b-e726-4914-b6af-c0a23eef5f31)


### Testing

To test it please use stake and unstake forms. Type some value or click
"max" and try to unstake/stake - check the value that is displayed on
the wallet token allowance screen (make sure you don't have any
allowance set on the TAHO or veTAHO on that account so you'll be able to
test it that way)

- [x] test it with super small amounts (up to 18 decimal places)
- [x] test it with big amounts (for example >=3301 TAHO)
- [x] test it with big amounts with some values on decimal places
(`3000.0000000001`)
  • Loading branch information
xpaczka authored Nov 16, 2023
2 parents 28b01f4 + 64418ac commit 6fb86a2
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 50 deletions.
1 change: 1 addition & 0 deletions src/shared/components/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default function SharedInput({
<div className="input_box">
<input
type={type}
step="any"
min={isTypeNumber ? "0" : undefined}
className={classNames({ input_number: isTypeNumber })}
value={value}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/components/Staking/UnstakeCooldown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function UnstakeCooldown({
const veTahoBalance = useDappSelector((state) =>
selectTokenBalanceByAddress(state, displayedRealmVeTokenAddress)
)
const veTahoUserAmount = bigIntToDisplayUserAmount(veTahoBalance)
const veTahoUserAmount = bigIntToDisplayUserAmount(veTahoBalance, 18, 5)

return (
<>
Expand Down
67 changes: 49 additions & 18 deletions src/shared/components/TokenAmountInput.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react"
import React, { useCallback, useEffect, useState } from "react"
import {
userAmountToBigInt,
bigIntToDisplayUserAmount,
bigIntToUserAmount,
bigIntToPreciseUserAmount,
} from "shared/utils"
import {
selectTokenBalanceByAddress,
Expand Down Expand Up @@ -46,42 +46,72 @@ export default function TokenAmountInput({
}: {
label?: string
inputLabel: string
amount: string
amount: bigint | null
tokenAddress: string
disabled?: boolean
onChange: (value: string) => void
onChange: (value: bigint | null) => void
onValidate?: (value: boolean) => void
}) {
const [textAmount, setTextAmount] = useState("")

const balance = useDappSelector((state) =>
selectTokenBalanceByAddress(state, tokenAddress)
)
const symbol = useDappSelector((state) =>
selectTokenSymbolByAddress(state, tokenAddress)
)

const validate = (value: string) => {
const result = handleValidate(value, balance)
const hasError = "error" in result
const validate = useCallback(
(value: string) => {
const result = handleValidate(value, balance)
const hasError = "error" in result

onValidate?.(!hasError)
return result
}
onValidate?.(!hasError)
return result
},
[balance, onValidate]
)

useEffect(() => {
const textToBigIntAmount =
textAmount === "" ? null : userAmountToBigInt(textAmount) ?? 0n

const bigIntToTextAmount = bigIntToPreciseUserAmount(balance)

// As we may be loosing some precision, we need to compare the values.
// Clicking "Max" button may result in bigint that is too big to be
// represented as a float number. In this case we need to compare values to
// not override the external value that stores the bigint using greater precision.
if (textToBigIntAmount !== amount && textAmount !== bigIntToTextAmount) {
onChange(textToBigIntAmount)
}

// Make sure this is working only one way:
// from the text provided by input to the parent component
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textAmount, onChange])

useEffect(() => {
// Allow clearing the input from parent componentthis should be the only case
// where parent component is allowed to change the value
if (amount === null) {
setTextAmount("")
}
}, [amount])

const parsedBalance = bigIntToDisplayUserAmount(balance, 18, 4)

return (
<div>
{label && (
<div className="label">{`${label} ${bigIntToDisplayUserAmount(
balance,
18,
5
)} ${symbol}`}</div>
<div className="label">{`${label} ${parsedBalance} ${symbol}`}</div>
)}
<SharedInput
type="number"
label={inputLabel}
value={amount}
value={textAmount}
disabled={disabled}
onChange={onChange}
onChange={setTextAmount}
validate={validate}
rightComponent={
<Button
Expand All @@ -90,7 +120,8 @@ export default function TokenAmountInput({
isDisabled={disabled}
onMouseDown={(event) => {
event.preventDefault()
onChange(bigIntToUserAmount(balance, 18, 18))
setTextAmount(bigIntToPreciseUserAmount(balance))
onChange(balance)
}}
>
Max
Expand Down
21 changes: 18 additions & 3 deletions src/shared/utils/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,26 @@ export function wait(ms: number): Promise<void> {
/**
* Checks the validity of the entered value from input for token amount.
* The value shouldn't be:
* - a empty string
* - a string or other non-numeric value
* - null
* - equal or less than zero.
* - an empty string
* - a string or other non-numeric value
*/
export function isValidInputAmount(amount: string): boolean {
export function isValidInputAmount(
amount: string | number | bigint | null
): boolean {
if (amount === null) {
return false
}

if (typeof amount === "bigint") {
return amount > 0n
}

if (typeof amount === "number") {
return amount > 0
}

return (
!!amount.trim() &&
!Number.isNaN(parseFloat(amount)) &&
Expand Down
38 changes: 36 additions & 2 deletions src/shared/utils/numbers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,22 +93,56 @@ export function bigIntToUserAmount(
const desiredDecimalsAmount =
amount / 10n ** BigInt(Math.max(0, decimals - desiredDecimals))

if (desiredDecimalsAmount > BigInt(Number.MAX_SAFE_INTEGER)) {
// eslint-disable-next-line no-console
console.warn(
`bigIntToUserAmount: amount ${amount} is too big to be represented as a number`
)
}

return (
Number(desiredDecimalsAmount) /
10 ** Math.min(desiredDecimals, decimals)
).toString()
}

// Parse token amount by moving the decimal point and separate thousands by comma
export function bigIntToPreciseUserAmount(
amount: bigint,
decimals = 18
): string {
let currentPrecision = decimals

while (currentPrecision >= 0) {
const desiredDecimalsAmount =
amount / 10n ** BigInt(Math.max(0, decimals - currentPrecision))

if (desiredDecimalsAmount <= BigInt(Number.MAX_SAFE_INTEGER)) {
return bigIntToUserAmount(amount, decimals, currentPrecision)
}

currentPrecision -= 1
}

return "0"
}

// Parse token amount by moving the decimal point and separate thousands by comma.
// Gracefully handle amounts smaller than desired precision.
export function bigIntToDisplayUserAmount(
amount: bigint | string,
decimals = 18,
desiredDecimals = 2
): string {
const amountBigInt = typeof amount === "string" ? BigInt(amount) : amount

return separateThousandsByComma(
const parsed = separateThousandsByComma(
bigIntToUserAmount(amountBigInt, decimals, desiredDecimals),
desiredDecimals
)

if (parsed === "0" && amountBigInt > 0n) {
return `<${1 / 10 ** desiredDecimals}`
}

return parsed
}
13 changes: 6 additions & 7 deletions src/ui/Island/RealmDetails/StakingForms/StakeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
selectStakingRealmId,
selectDisplayedRealmName,
} from "redux-state"
import { isValidInputAmount, userAmountToBigInt } from "shared/utils"
import { isValidInputAmount } from "shared/utils"
import classNames from "classnames"
import { TAHO_ADDRESS } from "shared/constants"
import { TransactionProgressStatus } from "shared/types"
Expand All @@ -31,7 +31,7 @@ export default function StakeForm({ isDisabled }: { isDisabled: boolean }) {
const stakingRealmAddress = useDappSelector(selectStakingRealmAddress)
const stakingRealmId = useDappSelector(selectStakingRealmId)

const [stakeAmount, setStakeAmount] = useState("")
const [stakeAmount, setStakeAmount] = useState<bigint | null>(null)
const [isStakeAmountValid, setIsStakeAmountValid] = useState(false)

const { updateAssistant } = useAssistant()
Expand All @@ -49,13 +49,12 @@ export default function StakeForm({ isDisabled }: { isDisabled: boolean }) {
const posthog = usePostHog()

const stakeTransaction = () => {
const amount = userAmountToBigInt(stakeAmount)
if (displayedRealmAddress && amount) {
if (displayedRealmAddress && stakeAmount) {
dispatch(
stakeTaho({
id: STAKE_TX_ID,
realmContractAddress: displayedRealmAddress,
amount,
amount: stakeAmount,
})
)
posthog?.capture("Realm stake started", {
Expand Down Expand Up @@ -84,14 +83,14 @@ export default function StakeForm({ isDisabled }: { isDisabled: boolean }) {
})

setIsStakeTransactionModalOpen(false)
setStakeAmount("")
setStakeAmount(null)
dispatch(stopTrackingTransactionStatus(STAKE_TX_ID))
if (!stakingRealmId) {
updateAssistant({ visible: true, type: "quests" })
}
}, [posthog, displayedRealmName, dispatch, stakingRealmId, updateAssistant])

const onInputChange = (value: string) => {
const onInputChange = (value: bigint | null) => {
setStakeAmount(value)

if (stakeTransactionStatus === TransactionProgressStatus.Failed) {
Expand Down
19 changes: 11 additions & 8 deletions src/ui/Island/RealmDetails/StakingForms/UnstakeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
selectTokenBalanceByAddress,
selectDisplayedRealmName,
} from "redux-state"
import { isValidInputAmount, userAmountToBigInt } from "shared/utils"
import { isValidInputAmount } from "shared/utils"
import classNames from "classnames"
import UnstakeCooldown from "shared/components/Staking/UnstakeCooldown"
import { TransactionProgressStatus } from "shared/types"
Expand Down Expand Up @@ -41,8 +41,7 @@ export default function UnstakeForm({ isDisabled }: { isDisabled: boolean }) {
const veTahoBalance = useDappSelector((state) =>
selectTokenBalanceByAddress(state, displayedRealmVeTokenAddress)
)
const [unstakeAmount, setUnstakeAmount] = useState("")
const amount = userAmountToBigInt(unstakeAmount)
const [unstakeAmount, setUnstakeAmount] = useState<bigint | null>(null)

const [isUnstakeAmountValid, setIsUnstakeAmountValid] = useState(false)

Expand All @@ -62,13 +61,17 @@ export default function UnstakeForm({ isDisabled }: { isDisabled: boolean }) {
const posthog = usePostHog()

const unstakeTransaction = () => {
if (displayedRealmAddress && displayedRealmVeTokenAddress && amount) {
if (
displayedRealmAddress &&
displayedRealmVeTokenAddress &&
unstakeAmount
) {
dispatch(
unstakeTaho({
id: UNSTAKE_TX_ID,
realmContractAddress: displayedRealmAddress,
veTokenContractAddress: displayedRealmVeTokenAddress,
amount,
amount: unstakeAmount,
})
)
}
Expand All @@ -92,7 +95,7 @@ export default function UnstakeForm({ isDisabled }: { isDisabled: boolean }) {

setIsUnstakeTransactionModalOpen(false)
setIsLeavingRealmModalOpen(false)
setUnstakeAmount("")
setUnstakeAmount(null)
dispatch(stopTrackingTransactionStatus(UNSTAKE_TX_ID))
updateAssistant({ visible: false, type: "default" })
}, [dispatch, displayedRealmName, posthog, updateAssistant])
Expand All @@ -109,7 +112,7 @@ export default function UnstakeForm({ isDisabled }: { isDisabled: boolean }) {
[dispatch]
)

const onInputChange = (value: string) => {
const onInputChange = (value: bigint | null) => {
setUnstakeAmount(value)

if (unstakeTransactionStatus === TransactionProgressStatus.Failed) {
Expand All @@ -118,7 +121,7 @@ export default function UnstakeForm({ isDisabled }: { isDisabled: boolean }) {
}

const onClickUnstake = () => {
if (amount === veTahoBalance) {
if (unstakeAmount === veTahoBalance) {
setIsLeavingRealmModalOpen(true)
} else {
setIsUnstakeTransactionModalOpen(true)
Expand Down
19 changes: 8 additions & 11 deletions src/ui/LiquidityPool/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
useDappDispatch,
useDappSelector,
} from "redux-state"
import { isValidInputAmount, userAmountToBigInt } from "shared/utils"
import { isValidInputAmount } from "shared/utils"
import { useArbitrumProvider } from "shared/hooks"
import Button from "shared/components/Button"
import Modal from "shared/components/Modal"
Expand All @@ -23,27 +23,24 @@ export default function LiquidityPool() {
const provider = useArbitrumProvider()
const isConnected = useDappSelector(selectIsWalletConnected)

const [tahoAmount, setTahoAmount] = useState("")
const [ethAmount, setEthAmount] = useState("")
const [tahoAmount, setTahoAmount] = useState<bigint | null>(null)
const [ethAmount, setEthAmount] = useState<bigint | null>(null)

const joinPool = async () => {
try {
if (!provider || !address) {
throw new Error("No provider or address")
}

const targetTahoAmount = userAmountToBigInt(tahoAmount)
const targetEthAmount = userAmountToBigInt(ethAmount)

if (!targetTahoAmount || !targetEthAmount) {
if (tahoAmount === null || ethAmount === null) {
throw new Error("Invalid token amount")
}

const receipt = await dispatch(
joinTahoPool({
id: LP_TX_ID,
tahoAmount: targetTahoAmount,
ethAmount: targetEthAmount,
tahoAmount,
ethAmount,
})
)

Expand All @@ -52,8 +49,8 @@ export default function LiquidityPool() {
// eslint-disable-next-line no-console
console.log(receipt)
dispatch(stopTrackingTransactionStatus(LP_TX_ID))
setTahoAmount("")
setEthAmount("")
setTahoAmount(null)
setEthAmount(null)
}
} catch (err) {
// TODO Add error handing
Expand Down

0 comments on commit 6fb86a2

Please sign in to comment.