diff --git a/eslint.config.js b/eslint.config.js index 3c8a061..23350e4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,6 +13,7 @@ module.exports = defineConfig([ }, rules: { 'prettier/prettier': 'error', + 'import/no-named-as-default': 'off', }, }, { diff --git a/package-lock.json b/package-lock.json index d9c8c29..8519de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,9 @@ "@tetherto/wdk-uikit-react-native": "^1.0.0-beta.2", "@ton/core": "^0.62.0", "b4a": "^1.7.2", + "bignumber.js": "^9.3.1", "bip39": "^3.1.0", "browserify-zlib": "^0.2.0", - "decimal.js": "^10.6.0", "events": "^3.3.0", "expo": "~54.0.8", "expo-build-properties": "~1.0.9", @@ -4439,6 +4439,177 @@ "node": ">=8" } }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/reporters/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@jest/reporters/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4498,6 +4669,53 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/transform": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", @@ -7736,6 +7954,15 @@ "event-target-shim": "^5.0.1" } }, + "node_modules/@wdk/wallet-btc/node_modules/bignumber.js": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/@wdk/wallet-evm": { "version": "1.0.0-beta.1", "resolved": "git+ssh://git@github.com/tetherto/wdk-wallet-evm.git#74e281ea63f7df6e2590580bba988daeda021df6", @@ -9880,9 +10107,9 @@ } }, "node_modules/bignumber.js": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", - "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" @@ -10321,6 +10548,12 @@ "integrity": "sha512-d7TeT8m2HuymDjSEmMppWe/h5SSPPUZkaWKrAofx6gNXDdZ3FL/81oOTGPG+LIaZbNr9m4rtUi98Yw0Q1vHIIw==", "license": "MIT" }, + "node_modules/bundle": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bundle/-/bundle-2.1.0.tgz", + "integrity": "sha512-d7TeT8m2HuymDjSEmMppWe/h5SSPPUZkaWKrAofx6gNXDdZ3FL/81oOTGPG+LIaZbNr9m4rtUi98Yw0Q1vHIIw==", + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", diff --git a/package.json b/package.json index 6238698..1e61973 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,9 @@ "@tetherto/wdk-uikit-react-native": "^1.0.0-beta.2", "@ton/core": "^0.62.0", "b4a": "^1.7.2", + "bignumber.js": "^9.3.1", "bip39": "^3.1.0", "browserify-zlib": "^0.2.0", - "decimal.js": "^10.6.0", "events": "^3.3.0", "expo": "~54.0.8", "expo-build-properties": "~1.0.9", diff --git a/src/app/activity.tsx b/src/app/activity.tsx index 8025f1b..dbe8e50 100644 --- a/src/app/activity.tsx +++ b/src/app/activity.tsx @@ -3,25 +3,23 @@ import { Transaction, TransactionList } from '@tetherto/wdk-uikit-react-native'; import React, { useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { assetConfig } from '../config/assets'; -import { FiatCurrency, pricingService } from '../services/pricing-service'; -import formatTokenAmount from '@/utils/format-token-amount'; -import formatUSDValue from '@/utils/format-usd-value'; +import { assetConfig } from '@/config/assets'; +import { FiatCurrency, pricingService } from '@/services/pricing-service'; import Header from '@/components/header'; import { colors } from '@/constants/colors'; +import { formatTokenAmount, formatUSDValue, bn } from '@/utils'; export default function ActivityScreen() { const insets = useSafeAreaInsets(); const { transactions: walletTransactions, addresses } = useWallet(); const [transactions, setTransactions] = useState([]); - // Transform wallet transactions to display format with fiat values const getTransactionsWithFiatValues = async () => { if (!walletTransactions.list) return []; // Get the wallet's own addresses for comparison const walletAddresses = addresses - ? Object.values(addresses).map(addr => addr.toLowerCase()) + ? Object.values(addresses).map((addr) => addr.toLowerCase()) : []; // Sort transactions by timestamp (newest first) and calculate fiat values @@ -31,7 +29,7 @@ export default function ActivityScreen() { .map(async (tx, index) => { const fromAddress = tx.from?.toLowerCase(); const isSent = walletAddresses.includes(fromAddress); - const amount = parseFloat(tx.amount); + const amount = bn(tx.amount); const config = assetConfig[tx.token as keyof typeof assetConfig]; // Calculate fiat amount using pricing service diff --git a/src/app/assets.tsx b/src/app/assets.tsx index 012d202..d4c2e64 100644 --- a/src/app/assets.tsx +++ b/src/app/assets.tsx @@ -4,12 +4,11 @@ import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; import React, { useEffect, useState } from 'react'; import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Asset, assetConfig } from '../config/assets'; -import formatAmount from '@/utils/format-amount'; -import getDisplaySymbol from '@/utils/get-display-symbol'; -import formatTokenAmount from '@/utils/format-token-amount'; +import { Asset, assetConfig } from '@/config/assets'; import Header from '@/components/header'; import { colors } from '@/constants/colors'; +import BigNumber from 'bignumber.js'; +import { formatAmount, getDisplaySymbol, formatTokenAmount, add, bn } from '@/utils'; export default function AssetsScreen() { const insets = useSafeAreaInsets(); @@ -21,13 +20,13 @@ export default function AssetsScreen() { const getAssetsWithFiatValue = async () => { if (!balances.list) return []; - const balanceMap = new Map(); + const balanceMap = new Map(); // Sum up balances by denomination across all networks balances.list.forEach(balance => { - const current = balanceMap.get(balance.denomination) || { totalBalance: 0 }; + const current = balanceMap.get(balance.denomination) || { totalBalance: bn('0') }; balanceMap.set(balance.denomination, { - totalBalance: current.totalBalance + parseFloat(balance.value), + totalBalance: add(current.totalBalance, bn(balance.value)), }); }); @@ -63,7 +62,7 @@ export default function AssetsScreen() { // Sort by USD value descending return assetList.sort((a, b) => { - return b.fiatValue - a.fiatValue; + return b.fiatValue.toNumber() - a.fiatValue.toNumber(); }); }; diff --git a/src/app/authorize.tsx b/src/app/authorize.tsx index 1cde1e7..14cac99 100644 --- a/src/app/authorize.tsx +++ b/src/app/authorize.tsx @@ -4,7 +4,6 @@ import { Fingerprint, Shield } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import parseWorkletError from '@/utils/parse-worklet-error'; import { colors } from '@/constants/colors'; import getErrorMessage from '@/utils/get-error-message'; diff --git a/src/app/receive/select-token.tsx b/src/app/receive/select-token.tsx index df0c6f7..be8690b 100644 --- a/src/app/receive/select-token.tsx +++ b/src/app/receive/select-token.tsx @@ -1,6 +1,5 @@ import { assetConfig } from '@/config/assets'; -import getDisplaySymbol from '@/utils/get-display-symbol'; -import { getRecentTokens, addToRecentTokens } from '@/utils/recent-tokens'; +import { getRecentTokens, addToRecentTokens, getDisplaySymbol } from '@/utils'; import { useWallet } from '@tetherto/wdk-react-native-provider'; import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; import { ArrowLeft, Search, X } from 'lucide-react-native'; diff --git a/src/app/send/details.tsx b/src/app/send/details.tsx index b9975b0..149b3a6 100644 --- a/src/app/send/details.tsx +++ b/src/app/send/details.tsx @@ -16,8 +16,18 @@ import { getAssetTicker, getNetworkType, calculateGasFee, - type GasFeeEstimate, -} from '@/utils/gas-fee-calculator'; + GasFeeEstimate, + getDisplaySymbol, + formatTokenAmount, + formatUSDValue, + validateAddressByNetwork, + bn, + gt, + div, + lte, + sub, + mul, +} from '@/utils'; import { Alert, Keyboard, @@ -34,12 +44,8 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Clipboard from 'expo-clipboard'; -import getDisplaySymbol from '@/utils/get-display-symbol'; -import formatTokenAmount from '@/utils/format-token-amount'; -import formatUSDValue from '@/utils/format-usd-value'; import Header from '@/components/header'; import { toast } from 'sonner-native'; -import { validateAddressByNetwork } from '@/utils/address-validators'; import { useDebouncedCallback } from '@/hooks/use-debounced-callback'; export default function SendDetailsScreen() { @@ -84,7 +90,7 @@ export default function SendDetailsScreen() { txId?: { fee: string; hash: string }; error?: string; } | null>(null); - const [tokenPrice, setTokenPrice] = useState(0); + const [tokenPrice, setTokenPrice] = useState(bn(0)); const [isAmountInputFocused, setIsAmountInputFocused] = useState(false); const keyboard = useKeyboard(); @@ -139,11 +145,11 @@ export default function SendDetailsScreen() { const calculateTokenPrice = async () => { try { const assetTicker = getAssetTicker(tokenId); - const price = await pricingService.getFiatValue(1, assetTicker, FiatCurrency.USD); - setTokenPrice(price); + const price = await pricingService.getFiatValue(bn(1), assetTicker, FiatCurrency.USD); + setTokenPrice(bn(price)); } catch (error) { console.error('Failed to get token price:', error); - setTokenPrice(0); + setTokenPrice(bn(0)); } }; @@ -153,11 +159,13 @@ export default function SendDetailsScreen() { // Helper function to convert amount to token value based on input mode const getTokenAmount = useCallback( (amountValue: string) => { - const numericAmount = parseFloat(amountValue.replace(/,/g, '')); - if (inputMode === 'fiat' && tokenPrice > 0) { - return numericAmount / tokenPrice; + const normalized = amountValue.replace(/,/g, ''); + const amountValueBn = normalized ? bn(normalized) : bn(0); + if (inputMode === 'fiat' && gt(tokenPrice, 0)) { + return div(amountValueBn, tokenPrice); } - return numericAmount; + + return amountValueBn; }, [inputMode, tokenPrice] ); @@ -171,9 +179,9 @@ export default function SendDetailsScreen() { } // Convert amount to token value if provided - const numericAmount = amountValue ? getTokenAmount(amountValue) : undefined; + const tokenAmountBn = amountValue ? getTokenAmount(amountValue) : undefined; - const estimate = await calculateGasFee(networkId, tokenId, numericAmount); + const estimate = await calculateGasFee(networkId, tokenId, tokenAmountBn); setGasEstimate(estimate); setIsLoadingGasEstimate(false); @@ -192,7 +200,8 @@ export default function SendDetailsScreen() { // For BTC, calculate gas fee when amount changes useEffect(() => { const isBtc = tokenId.toLowerCase() === 'btc'; - if (isBtc && amount && parseFloat(amount) > 0) { + const amountBn = amount ? bn(amount.replace(/,/g, '') || bn(0)) : bn(0); + if (isBtc && gt(amountBn, 0)) { handleCalculateGasFee(true, amount); } }, [amount, tokenId, handleCalculateGasFee]); @@ -204,14 +213,17 @@ export default function SendDetailsScreen() { try { const assetTicker = getAssetTicker(tokenId); const price = await pricingService.getFiatValue(1, assetTicker, FiatCurrency.USD); - setTokenPrice(price); + setTokenPrice(bn(price)); } catch (error) { console.error('Failed to refresh token price:', error); } // Refetch gas fee without showing loading state const isBtc = tokenId.toLowerCase() === 'btc'; - if (!isBtc || (isBtc && amount && parseFloat(amount) > 0)) { + const normalized = (amount ?? '').replace(/,/g, '').trim(); + const amountBn = normalized && normalized !== '.' ? bn(normalized) : bn(0); + + if (!isBtc || (isBtc && gt(amountBn, 0))) { handleCalculateGasFee(false, amount); } }, 30000); // 30 seconds @@ -249,26 +261,25 @@ export default function SendDetailsScreen() { }, [handleRecipientAddressChange]); const handleUseMax = useCallback(() => { - const numericBalance = parseFloat(tokenBalance.replace(/,/g, '')); - const numericBalanceUSD = parseFloat(tokenBalanceUSD.replace(/[$,]/g, '')); - + const balanceBn = bn(tokenBalance.replace(/,/g, '')); + const balanceUsdBn = bn(tokenBalanceUSD.replace(/[$,]/g, '')); if (inputMode === 'token') { // Subtract gas fee from token balance - let maxAmount = numericBalance; + let maxAmountBn = balanceBn; if (gasEstimate.fee !== undefined) { - maxAmount = Math.max(0, numericBalance - gasEstimate.fee); + maxAmountBn = sub(balanceBn, gasEstimate.fee); toast.info('To avoid transaction failure, the max amount has been reduced by the gas fee'); } - setAmount(maxAmount.toString()); + setAmount(maxAmountBn.toString()); } else { // Subtract gas fee (converted to USD) from balance USD - let maxAmountUSD = numericBalanceUSD; - if (gasEstimate.fee !== undefined && tokenPrice > 0) { - const gasFeeUSD = gasEstimate.fee * tokenPrice; - maxAmountUSD = Math.max(0, numericBalanceUSD - gasFeeUSD); + let maxUsdBn = balanceUsdBn; + if (gasEstimate.fee !== undefined && gt(tokenPrice, 0)) { + const gasFeeUsdBn = mul(gasEstimate.fee, tokenPrice); + maxUsdBn = sub(balanceUsdBn, gasFeeUsdBn); } // Format fiat amount to 2 decimal places - setAmount(maxAmountUSD.toFixed(2)); + setAmount(maxUsdBn.toFixed(2)); } setAmountError(null); }, [inputMode, tokenBalance, tokenBalanceUSD, gasEstimate.fee, tokenPrice]); @@ -282,25 +293,25 @@ export default function SendDetailsScreen() { // Validate amount when it changes const validateAmount = useCallback( (value: string) => { - if (!value || parseFloat(value) <= 0) { + const normalized = value.replace(/,/g, ''); + const amountBn = normalized ? bn(normalized) : bn(0); + + if (!value || lte(amountBn, 0)) { setAmountError(null); return; } - const numericBalance = parseFloat(tokenBalance.replace(/,/g, '')); - const numericBalanceUSD = parseFloat(tokenBalanceUSD.replace(/[$,]/g, '')); - const numericAmount = parseFloat(value.replace(/,/g, '')); + const balanceBn = bn(tokenBalance.replace(/,/g, '')); + const balanceUsdBn = bn(tokenBalanceUSD.replace(/[$,]/g, '')); if (inputMode === 'token') { - if (numericAmount > numericBalance) { - setAmountError( - `Maximum: ${formatTokenAmount(numericBalance, tokenSymbol as AssetTicker)}` - ); + if (gt(amountBn, balanceBn)) { + setAmountError(`Maximum: ${formatTokenAmount(balanceBn, tokenSymbol as AssetTicker)}`); } else { setAmountError(null); } } else { - if (numericAmount > numericBalanceUSD) { + if (gt(amountBn, balanceUsdBn)) { setAmountError(`Maximum: ${tokenBalanceUSD}`); } else { setAmountError(null); @@ -321,7 +332,6 @@ export default function SendDetailsScreen() { // Prevent multiple decimal points const parts = normalized.split('.'); const formatted = parts[0] + (parts.length > 1 ? '.' + parts[1] : ''); - setAmount(formatted); validateAmount(formatted); }, @@ -351,16 +361,17 @@ export default function SendDetailsScreen() { return false; } - if (!amount || parseFloat(amount) <= 0) { + // Check if amount exceeds balance + const normalizedAmount = amount.replace(/,/g, ''); + const amountBn = normalizedAmount ? bn(normalizedAmount) : bn(0); + const balanceBn = bn(tokenBalance.replace(/,/g, '')); + + if (!amount || lte(amountBn, 0)) { Alert.alert('Error', 'Please enter a valid amount'); return false; } - // Check if amount exceeds balance - const numericBalance = parseFloat(tokenBalance.replace(',', '')); - const numericAmount = parseFloat(amount.replace(',', '')); - - if (inputMode === 'token' && numericAmount > numericBalance) { + if (inputMode === 'token' && gt(amountBn, balanceBn)) { Alert.alert('Error', 'Insufficient balance'); return false; } @@ -381,15 +392,15 @@ export default function SendDetailsScreen() { const assetTicker = getAssetTicker(tokenId); // Convert fiat to token amount if in fiat mode - let numericAmount = parseFloat(amount); - if (inputMode === 'fiat' && tokenPrice > 0) { - numericAmount = numericAmount / tokenPrice; + let amountBn = bn(amount.replace(/,/g, '')); + if (inputMode === 'fiat' && gt(tokenPrice, 0)) { + amountBn = div(amountBn, tokenPrice); } const sendResult = await WDKService.sendByNetwork( networkType, 0, // account index - numericAmount, + amountBn.toNumber(), recipientAddress, assetTicker ); @@ -425,9 +436,12 @@ export default function SendDetailsScreen() { const balanceDisplay = useMemo(() => { if (inputMode === 'token') { - return `Balance: ${formatTokenAmount(parseFloat(tokenBalance), tokenSymbol as AssetTicker)}`; + return `Balance: ${formatTokenAmount( + bn(tokenBalance.replace(/,/g, '')), + tokenSymbol as AssetTicker + )}`; } - return `Balance: ${formatUSDValue(parseFloat(tokenBalanceUSD))}`; + return `Balance: ${formatUSDValue(bn(tokenBalanceUSD.replace(/[$,]/g, '')))}`; }, [inputMode, tokenBalance, tokenBalanceUSD, tokenSymbol]); const getFeeFromTransactionResult = ( @@ -435,19 +449,21 @@ export default function SendDetailsScreen() { token: AssetTicker ) => { const fee = transactionResult.txId?.fee; - if (!fee) return formatTokenAmount(0, token); + if (!fee) return formatTokenAmount(bn(0), token); const value = Number(fee) / WDKService.getDenominationValue(token); - return formatTokenAmount(value, token); + const valueBn = div(bn(fee), value); + return formatTokenAmount(valueBn, token); }; const getTransactionAmout = useCallback(() => { - const numericAmount = parseFloat(amount.replace(/,/g, '')); - if (inputMode === 'fiat' && tokenPrice > 0) { - return formatUSDValue(numericAmount); + const normalized = amount.replace(/,/g, '') || '0'; + const amountBn = bn(normalized); + if (inputMode === 'fiat' && gt(tokenPrice, 0)) { + return formatUSDValue(amountBn); } - return formatTokenAmount(parseFloat(amount || '0'), tokenSymbol as AssetTicker); + return formatTokenAmount(amountBn, tokenSymbol as AssetTicker); }, [inputMode, tokenPrice, amount, tokenSymbol]); const isUseMaxDisabled = useMemo(() => { @@ -559,15 +575,16 @@ export default function SendDetailsScreen() { Calculating... ) : gasEstimate.error ? ( {gasEstimate.error} - ) : tokenId.toLowerCase() === 'btc' && (!amount || parseFloat(amount) <= 0) ? ( + ) : tokenId.toLowerCase() === 'btc' && + (!amount || lte(bn(amount.replace(/,/g, '') || '0'), 0)) ? ( Insert amount for gas fee estimation ) : gasEstimate.fee !== undefined ? ( <> - {formatTokenAmount(gasEstimate.fee, tokenSymbol as AssetTicker)} + {formatTokenAmount(gasEstimate.fee ?? bn(0), tokenSymbol as AssetTicker)} - ≈ {formatUSDValue(gasEstimate.fee * tokenPrice)} + ≈ {formatUSDValue(mul(gasEstimate.fee ?? bn(0), tokenPrice))} ) : ( diff --git a/src/app/send/select-network.tsx b/src/app/send/select-network.tsx index ae693d1..f2e3978 100644 --- a/src/app/send/select-network.tsx +++ b/src/app/send/select-network.tsx @@ -1,7 +1,6 @@ import { Network, NetworkSelector } from '@/components/NetworkSelector'; import { assetConfig } from '@/config/assets'; import { networkConfigs } from '@/config/networks'; -import formatAmount from '@/utils/format-amount'; import { AssetTicker, useWallet } from '@tetherto/wdk-react-native-provider'; import { useLocalSearchParams } from 'expo-router'; import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; @@ -9,8 +8,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FiatCurrency, pricingService } from '@/services/pricing-service'; -import getDisplaySymbol from '@/utils/get-display-symbol'; -import formatTokenAmount from '@/utils/format-token-amount'; +import { getDisplaySymbol, formatAmount, formatTokenAmount, bn } from '@/utils'; import Header from '@/components/header'; import { colors } from '@/constants/colors'; @@ -46,7 +44,7 @@ export default function SelectNetworkScreen() { b => networkType === b.networkType && b.denomination === tokenId ); - const balanceValue = balance ? parseFloat(balance.value) : 0; + const balanceValue: BigNumber = balance ? bn(balance.value) : bn(0); // Calculate fiat value using pricing service const balanceUSD = await pricingService.getFiatValue( diff --git a/src/app/send/select-token.tsx b/src/app/send/select-token.tsx index 114e771..495f906 100644 --- a/src/app/send/select-token.tsx +++ b/src/app/send/select-token.tsx @@ -5,22 +5,27 @@ import React, { useCallback, useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors } from '@/constants/colors'; - import { AssetTicker, useWallet } from '@tetherto/wdk-react-native-provider'; import { AssetSelector, type Token } from '@tetherto/wdk-uikit-react-native'; import { FiatCurrency, pricingService } from '@/services/pricing-service'; -import formatAmount from '@/utils/format-amount'; -import getDisplaySymbol from '@/utils/get-display-symbol'; -import { getRecentTokens, addToRecentTokens } from '@/utils/recent-tokens'; -import formatTokenAmount from '@/utils/format-token-amount'; import Header from '@/components/header'; +import BigNumber from 'bignumber.js'; +import { + formatAmount, + formatTokenAmount, + getRecentTokens, + addToRecentTokens, + getDisplaySymbol, + bn, + add, + gt, +} from '@/utils'; export default function SelectTokenScreen() { const insets = useSafeAreaInsets(); const router = useDebouncedNavigation(); const params = useLocalSearchParams(); const { wallet, balances } = useWallet(); - // Get the scanned address from params (passed from QR scanner) const { scannedAddress } = params as { scannedAddress?: string }; const [recentTokens, setRecentTokens] = useState([]); @@ -43,12 +48,12 @@ export default function SelectTokenScreen() { } // Group balances by denomination - const balanceMap = new Map(); + const balanceMap = new Map(); balances.list.forEach((balance) => { - const current = balanceMap.get(balance.denomination) || { totalBalance: 0 }; + const current = balanceMap.get(balance.denomination) || { totalBalance: bn(0) }; balanceMap.set(balance.denomination, { - totalBalance: current.totalBalance + parseFloat(balance.value), + totalBalance: add(current.totalBalance, bn(balance.value)), }); }); @@ -60,10 +65,10 @@ export default function SelectTokenScreen() { const config = assetConfig[assetSymbol as keyof typeof assetConfig]; if (!config) continue; - const totalBalance = balanceMap.get(assetSymbol)?.totalBalance || 0; + const totalBalance = balanceMap.get(assetSymbol)?.totalBalance || bn('0'); // Calculate fiat value using pricing service - let usdValue = 0; + let usdValue = bn(0); try { usdValue = await pricingService.getFiatValue( totalBalance, @@ -73,7 +78,7 @@ export default function SelectTokenScreen() { } catch (error) { console.error(`Error calculating fiat value for ${assetSymbol}:`, error); // Fallback to 0 if pricing service fails - usdValue = 0; + usdValue = bn(0); } tokensWithBalances.push({ @@ -84,7 +89,7 @@ export default function SelectTokenScreen() { balanceUSD: `${formatAmount(usdValue)} USD`, icon: config.icon, color: config.color, - hasBalance: totalBalance > 0, + hasBalance: gt(totalBalance, 0), }); } diff --git a/src/app/token-details.tsx b/src/app/token-details.tsx index 43f4274..d29612a 100644 --- a/src/app/token-details.tsx +++ b/src/app/token-details.tsx @@ -1,17 +1,16 @@ import { assetConfig } from '@/config/assets'; -import formatAmount from '@/utils/format-amount'; import { AssetTicker, NetworkType, useWallet } from '@tetherto/wdk-react-native-provider'; import { useLocalSearchParams } from 'expo-router'; import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; import React, { useEffect, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { TokenDetails } from '../components/TokenDetails'; -import { FiatCurrency, pricingService } from '../services/pricing-service'; +import { TokenDetails } from '@/components/TokenDetails'; +import { FiatCurrency, pricingService } from '@/services/pricing-service'; import { networkConfigs } from '@/config/networks'; -import getDisplaySymbol from '@/utils/get-display-symbol'; import Header from '@/components/header'; import { colors } from '@/constants/colors'; +import { formatAmount, getDisplaySymbol, add, bn, gt } from '@/utils'; export default function TokenDetailsScreen() { const router = useDebouncedNavigation(); @@ -27,15 +26,15 @@ export default function TokenDetailsScreen() { name: string; icon: any; color: string; - totalBalance: number; - totalUSDValue: number; + totalBalance: BigNumber; + totalUSDValue: BigNumber; networkBalances: { network: string; - balance: number; - usdValue: number; + balance: BigNumber; + usdValue: BigNumber; address: string; }[]; - priceUSD: number; + priceUSD: BigNumber; } | null>(null); // Calculate token balances from wallet data with async pricing @@ -47,13 +46,13 @@ export default function TokenDetailsScreen() { } // Filter balances for this specific token - const tokenBalances = balances.list.filter(balance => balance.denomination === tokenSymbol); + const tokenBalances = balances.list.filter((balance) => balance.denomination === tokenSymbol); // Calculate total balance and network breakdown with fiat values - let totalBalance = 0; - const networkBalancesPromises = tokenBalances.map(async balance => { - const amount = parseFloat(balance.value); - totalBalance += amount; + let totalBalance = bn('0'); + const networkBalancesPromises = tokenBalances.map(async (balance) => { + const amount = bn(balance.value); + totalBalance = add(totalBalance, amount); // Calculate fiat value using pricing service const usdValue = await pricingService.getFiatValue( @@ -70,12 +69,12 @@ export default function TokenDetailsScreen() { }; }); - const networkBalances = (await Promise.all(networkBalancesPromises)).filter( - item => item.balance > 0 + const networkBalances = (await Promise.all(networkBalancesPromises)).filter((item) => + gt(item.balance, 0) ); const tokenPrice = await pricingService.getFiatValue( - 1, + bn('1'), tokenSymbol as AssetTicker, FiatCurrency.USD ); @@ -105,7 +104,7 @@ export default function TokenDetailsScreen() { if (!tokenData || !network) return; // Find the specific network balance - const networkBalance = tokenData.networkBalances.find(nb => nb.network === network); + const networkBalance = tokenData.networkBalances.find((nb) => nb.network === network); if (!networkBalance) return; // Capitalize network name (e.g., "polygon" -> "Polygon") diff --git a/src/app/wallet-setup/secure-wallet.tsx b/src/app/wallet-setup/secure-wallet.tsx index 90f73f3..0b79aaf 100644 --- a/src/app/wallet-setup/secure-wallet.tsx +++ b/src/app/wallet-setup/secure-wallet.tsx @@ -8,7 +8,6 @@ import React, { useEffect, useState } from 'react'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { getUniqueId } from 'react-native-device-info'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import parseWorkletError from '@/utils/parse-worklet-error'; import { toast } from 'sonner-native'; import { colors } from '@/constants/colors'; import getErrorMessage from '@/utils/get-error-message'; diff --git a/src/app/wallet.tsx b/src/app/wallet.tsx index e362329..4dc931c 100644 --- a/src/app/wallet.tsx +++ b/src/app/wallet.tsx @@ -25,18 +25,16 @@ import { View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { AssetConfig, assetConfig } from '../config/assets'; -import { FiatCurrency, pricingService } from '../services/pricing-service'; -import formatAmount from '@/utils/format-amount'; -import formatTokenAmount from '@/utils/format-token-amount'; -import formatUSDValue from '@/utils/format-usd-value'; +import { AssetConfig, assetConfig } from '@/config/assets'; +import { FiatCurrency, pricingService } from '@/services/pricing-service'; import useWalletAvatar from '@/hooks/use-wallet-avatar'; import { colors } from '@/constants/colors'; +import { formatAmount, formatTokenAmount, formatUSDValue, bn, add, gt } from '@/utils'; type AggregatedBalance = ({ denomination: string; - balance: number; - usdValue: number; + balance: BigNumber; + usdValue: BigNumber; config: AssetConfig; } | null)[]; @@ -50,7 +48,7 @@ type Transaction = { iconColor: string; blockchain: string; hash: string; - fiatAmount: number; + fiatAmount: BigNumber; currency: FiatCurrency; }; @@ -66,6 +64,7 @@ export default function WalletScreen() { addresses, transactions: walletTransactions, } = useWallet(); + const [refreshing, setRefreshing] = useState(false); const [aggregatedBalances, setAggregatedBalances] = useState([]); const [transactions, setTransactions] = useState([]); @@ -86,13 +85,14 @@ export default function WalletScreen() { const getAggregatedBalances = async () => { if (!balances) return []; - const map = new Map(); + const map = new Map(); // Sum up balances by denomination across all networks balances.list.forEach((balance) => { - const current = map.get(balance.denomination) || { totalBalance: 0 }; + const current = map.get(balance.denomination) || { totalBalance: bn(0) }; + map.set(balance.denomination, { - totalBalance: current.totalBalance + parseFloat(balance.value), + totalBalance: add(current.totalBalance, balance.value), }); }); @@ -117,13 +117,19 @@ export default function WalletScreen() { return (await Promise.all(promises)) .filter(Boolean) - .filter((asset) => asset && asset.balance > 0) // Only show tokens with positive balance - .sort((a, b) => (b?.usdValue || 0) - (a?.usdValue || 0)); // Sort by USD value descending + .filter((asset) => asset && gt(asset.balance, 0)) // Only show tokens with positive balance + .sort((a, b) => { + // Sort by USD value descending + return b!.usdValue.minus(a!.usdValue).toNumber(); + }); }; // Calculate total portfolio value const totalPortfolioValue = useMemo(() => { - return aggregatedBalances.reduce((sum, asset) => sum + (asset?.usdValue || 0), 0); + return aggregatedBalances.reduce( + (sum, asset) => (asset ? add(sum, asset.usdValue) : sum), + bn(0) + ); }, [aggregatedBalances]); // Animated border opacity based on scroll position @@ -173,7 +179,7 @@ export default function WalletScreen() { .map(async (tx, index) => { const fromAddress = tx.from?.toLowerCase(); const isSent = walletAddresses.includes(fromAddress); - const amount = parseFloat(tx.amount); + const amount = bn(tx.amount); const config = assetConfig[tx.token]; // Calculate fiat amount using pricing service @@ -335,7 +341,7 @@ export default function WalletScreen() { }} > {formatUSDValue(tokenData.totalUSDValue)} - {tokenData.priceUSD > 0 && ( + {gt(tokenData.priceUSD, 0) && ( ${formatAmount(tokenData.priceUSD)} per {tokenData.symbol} @@ -99,7 +97,7 @@ export function TokenDetails({ tokenData, onSendPress }: TokenDetailsProps) { {formatUSDValue(item.usdValue)} - {item.balance > 0 && ( + {gt(item.balance, 0) && ( handleSend(item.network as NetworkType)} diff --git a/src/config/assets.ts b/src/config/assets.ts index 73fe9c8..0904a28 100644 --- a/src/config/assets.ts +++ b/src/config/assets.ts @@ -1,5 +1,6 @@ import { FiatCurrency } from '@/services/pricing-service'; import { NetworkType } from '@tetherto/wdk-react-native-provider'; +import BigNumber from 'bignumber.js'; export interface AssetConfig { name: string; @@ -14,7 +15,7 @@ export interface Asset { name: string; symbol: string; amount: string; - fiatValue: number; + fiatValue: BigNumber; fiatCurrency: FiatCurrency; icon: string | any; color: string; diff --git a/src/services/__tests__/pricing-service.test.ts b/src/services/__tests__/pricing-service.test.ts new file mode 100644 index 0000000..f4c35eb --- /dev/null +++ b/src/services/__tests__/pricing-service.test.ts @@ -0,0 +1,100 @@ +import BigNumber from 'bignumber.js'; +import { pricingService, FiatCurrency } from '../pricing-service'; + +// Define local enum for type safety in tests +enum AssetTicker { + BTC = 'btc', + USDT = 'usdt', + XAUT = 'xaut', +} + +// ------------------- Mock external modules ------------------- + +jest.mock('@tetherto/wdk-react-native-provider', () => ({ + AssetTicker: { + BTC: 'btc', + USDT: 'usdt', + XAUT: 'xaut', + }, + useWallet: jest.fn(() => ({ + wallet: { id: 'test-wallet' }, + balances: { list: [], isLoading: false }, + })), +})); + +jest.mock('@tetherto/wdk-pricing-bitfinex-http', () => ({ + BitfinexPricingClient: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('@tetherto/wdk-pricing-provider', () => ({ + PricingProvider: jest.fn().mockImplementation(() => ({ + getLastPrice: jest.fn((asset: string) => { + switch (asset) { + case 'btc': + return Promise.resolve(60000); + case 'usdt': + return Promise.resolve(1); + case 'xaut': + return Promise.resolve(2000); + default: + return Promise.resolve(0); + } + }), + })), +})); + +// ------------------- Test suite ------------------- + +describe('PricingService', () => { + const service = pricingService; + + beforeEach(async () => { + (service as any).provider = null; + (service as any).fiatExchangeRateCache = undefined; + (service as any).isInitialized = false; + + await service.initialize(); + }); + + it('should initialize and populate exchange rates', () => { + expect(service.isReady()).toBe(true); + expect(service.getExchangeRate(AssetTicker.BTC, FiatCurrency.USD)).toBe(60000); + expect(service.getExchangeRate(AssetTicker.USDT, FiatCurrency.USD)).toBe(1); + expect(service.getExchangeRate(AssetTicker.XAUT, FiatCurrency.USD)).toBe(2000); + }); + + it('getFiatValue should return correct number', async () => { + const result = await service.getFiatValue(0.5, AssetTicker.BTC, FiatCurrency.USD); + expect(result.toNumber()).toBe(30000); + }); + + it('getFiatValue should return correct BN', async () => { + const valueBN = await service.getFiatValue(0.5, AssetTicker.BTC, FiatCurrency.USD); + expect(valueBN).toBeInstanceOf(BigNumber); + expect(valueBN.toString()).toBe(new BigNumber(0.5).multipliedBy(60000).toString()); + }); + + it('getFiatValue should work with BN input', async () => { + const inputBN = new BigNumber(0.25); + const valueBN = await service.getFiatValue(inputBN, AssetTicker.BTC, FiatCurrency.USD); + expect(valueBN.toString()).toBe(new BigNumber(0.25).multipliedBy(60000).toString()); + }); + + it('should throw error if service not initialized', async () => { + (service as any).isInitialized = false; + + await expect(service.getFiatValue(1, AssetTicker.BTC, FiatCurrency.USD)).rejects.toThrow( + 'Pricing service not initialized. Call initialize() first.' + ); + + await expect(service.getFiatValue(1, AssetTicker.BTC, FiatCurrency.USD)).rejects.toThrow( + 'Pricing service not initialized. Call initialize() first.' + ); + }); + + it('getFiatValue should throw if exchange rate missing', async () => { + await expect(service.getFiatValue(1, 'fake' as any, FiatCurrency.USD)).rejects.toThrow( + 'No exchange rate for fake -> USD' + ); + }); +}); diff --git a/src/services/pricing-service.ts b/src/services/pricing-service.ts index 6d30728..1a73e78 100644 --- a/src/services/pricing-service.ts +++ b/src/services/pricing-service.ts @@ -1,7 +1,8 @@ import { BitfinexPricingClient } from '@tetherto/wdk-pricing-bitfinex-http'; import { PricingProvider } from '@tetherto/wdk-pricing-provider'; import { AssetTicker } from '@tetherto/wdk-react-native-provider'; -import DecimalJS from 'decimal.js'; +import { BNValue, mul } from '@/utils/bignumber'; +import BigNumber from 'bignumber.js'; export enum FiatCurrency { USD = 'USD', @@ -49,12 +50,19 @@ class PricingService { } } - async getFiatValue(value: number, asset: AssetTicker, currency: FiatCurrency): Promise { + async getFiatValue( + value: BNValue, + asset: AssetTicker, + currency: FiatCurrency + ): Promise { if (!this.isInitialized || !this.fiatExchangeRateCache) { throw new Error('Pricing service not initialized. Call initialize() first.'); } - return new DecimalJS(value).mul(this.fiatExchangeRateCache[currency][asset]).toNumber(); + const rate = this.getExchangeRate(asset, currency); + if (!rate) throw new Error(`No exchange rate for ${asset} -> ${currency}`); + + return mul(value, rate); } async refreshExchangeRates(): Promise { diff --git a/src/utils/__tests__/bignumber.test.ts b/src/utils/__tests__/bignumber.test.ts new file mode 100644 index 0000000..ec40d1b --- /dev/null +++ b/src/utils/__tests__/bignumber.test.ts @@ -0,0 +1,103 @@ +import BigNumber from 'bignumber.js'; +import { bn, add, sub, mul, div, eq, gt, gte, lt, lte, BNValue } from '../bignumber'; + +describe('BN utility functions', () => { + // ------------------- bn() factory ------------------- + describe('bn()', () => { + it('should return the same BigNumber instance if input is already BN', () => { + const value = new BigNumber('123.45'); + expect(bn(value)).toBe(value); + }); + + it('should convert number to BN without precision loss', () => { + const value: number = 0.1 + 0.2; + const result = bn(value); + expect(result.toString()).toBe('0.30000000000000004'); + }); + + it('should convert string to BN', () => { + const value = '123.456'; + const result = bn(value); + expect(result.toString()).toBe('123.456'); + }); + + it('should return 0 and warn if input is invalid', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const result = bn('invalid' as any); + expect(result.toString()).toBe('0'); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + // ------------------- Arithmetic ------------------- + describe('arithmetic operations', () => { + const a: BNValue = '10.5'; + const b: BNValue = 2; + + it('add()', () => { + expect(add(a, b).toString()).toBe('12.5'); + }); + + it('sub()', () => { + expect(sub(a, b).toString()).toBe('8.5'); + }); + + it('mul()', () => { + expect(mul(a, b).toString()).toBe('21'); + }); + + it('div()', () => { + expect(div(a, b).toString()).toBe('5.25'); + }); + + it('div() by zero returns Infinity', () => { + expect(div(a, 0).isFinite()).toBe(false); + }); + }); + + // ------------------- Comparison ------------------- + describe('comparison operations', () => { + const a: BNValue = '5.5'; + const b: BNValue = 5.5; + const c: BNValue = '6'; + + it('eq()', () => { + expect(eq(a, b)).toBe(true); + expect(eq(a, c)).toBe(false); + }); + + it('gt()', () => { + expect(gt(c, a)).toBe(true); + expect(gt(a, c)).toBe(false); + }); + + it('gte()', () => { + expect(gte(a, b)).toBe(true); + expect(gte(a, c)).toBe(false); + }); + + it('lt()', () => { + expect(lt(a, c)).toBe(true); + expect(lt(c, a)).toBe(false); + }); + + it('lte()', () => { + expect(lte(a, b)).toBe(true); + expect(lte(c, a)).toBe(false); + }); + }); + + // ------------------- Large / small numbers ------------------- + describe('edge cases with large and small numbers', () => { + it('should handle very small decimals', () => { + const small = bn('0.0000000000000001234'); + expect(small.toString()).toBe('1.234e-16'); + }); + + it('should handle very large numbers', () => { + const large = bn('123456789012345678901234567890'); + expect(large.toFixed()).toBe('123456789012345678901234567890'); + }); + }); +}); diff --git a/src/utils/__tests__/format-amount.test.ts b/src/utils/__tests__/format-amount.test.ts index 9a46d39..8864951 100644 --- a/src/utils/__tests__/format-amount.test.ts +++ b/src/utils/__tests__/format-amount.test.ts @@ -1,38 +1,55 @@ +import BigNumber from 'bignumber.js'; import formatAmount from '../format-amount'; +import { bn } from '@/utils/bignumber'; describe('formatAmount', () => { - test('formats a number with default 2 fraction digits', () => { - expect(formatAmount(1234.5)).toBe('1,234.50'); + test('formats a number with default 2 decimal places', () => { + expect(formatAmount(bn(1234.5))).toBe('1,234.50'); }); - test('formats a number with a custom minimumFractionDigits', () => { - expect( - formatAmount(10, { minimumFractionDigits: 4, maximumFractionDigits: 4 }) - ).toBe('10.0000'); + test('formats a number with custom decimal places', () => { + expect(formatAmount(bn(10), 4)).toBe('10.0000'); }); - test('formats a number with a custom maximumFractionDigits', () => { - expect(formatAmount(3.14159, { maximumFractionDigits: 3 })).toBe('3.142'); + test('rounds a number using default ROUND_HALF_UP', () => { + expect(formatAmount(bn(3.14159), 3)).toBe('3.142'); }); - test('formats a number with both custom minimum and maximum fraction digits', () => { - expect( - formatAmount(99.1, { - minimumFractionDigits: 1, - maximumFractionDigits: 4, - }) - ).toBe('99.1'); + test('formats a number with fewer decimals without trailing zeros when decimalPlaces is smaller', () => { + expect(formatAmount(bn(99.1), 1)).toBe('99.1'); }); test('formats an integer value', () => { - expect(formatAmount(5000)).toBe('5,000.00'); + expect(formatAmount(bn(5000))).toBe('5,000.00'); }); test('formats zero correctly', () => { - expect(formatAmount(0)).toBe('0.00'); + expect(formatAmount(bn(0))).toBe('0.00'); }); test('formats negative values', () => { - expect(formatAmount(-1200.5)).toBe('-1,200.50'); + expect(formatAmount(bn(-1200.5))).toBe('-1,200.50'); + }); + + test('supports custom rounding mode', () => { + expect( + formatAmount( + bn(1.005), + 2, + BigNumber.ROUND_DOWN + ) + ).toBe('1.00'); + }); + + test('supports custom format options', () => { + const format: BigNumber.Format = { + decimalSeparator: ',', + groupSeparator: ' ', + groupSize: 3, + }; + + expect(formatAmount(bn(1234.56), 2, BigNumber.ROUND_HALF_UP, format)).toBe( + '1 234,56' + ); }); }); diff --git a/src/utils/__tests__/format-token-amount.test.ts b/src/utils/__tests__/format-token-amount.test.ts new file mode 100644 index 0000000..5507117 --- /dev/null +++ b/src/utils/__tests__/format-token-amount.test.ts @@ -0,0 +1,41 @@ +import formatTokenAmount from '../format-token-amount'; +import { bn } from '@/utils/bignumber'; + +jest.mock('@/config/assets', () => ({ + assetConfig: { + btc: { symbol: 'BTC' }, + usdt: { symbol: 'USD₮' }, + xaut: { symbol: 'XAU₮' }, + }, +})); + +import { AssetTicker } from '@tetherto/wdk-react-native-provider'; + +jest.mock('@tetherto/wdk-react-native-provider', () => ({ + AssetTicker: { + USDT: 'USDT', + BTC: 'BTC', + XAUT: 'XAUT', + }, +})); + +describe('formatTokenAmount (integration style)', () => { + const token = AssetTicker.USDT; + + test('returns "0.00 SYMBOL" for zero amount', () => { + const result = formatTokenAmount(bn(0), token); + expect(result).toBe('0.00 USD₮'); + }); + + test('does not append symbol when includeSymbol = false', () => { + const result = formatTokenAmount(bn(0), token, false); + expect(result).toBe('0.00'); + }); + + test('formats small non-zero amount correctly', () => { + const amount = bn(0.00123); + const result = formatTokenAmount(amount, token); + expect(result.endsWith('USD₮')).toBe(true); + expect(result).not.toBe('0.00 USD₮'); + }); +}); diff --git a/src/utils/bignumber.ts b/src/utils/bignumber.ts new file mode 100644 index 0000000..4009818 --- /dev/null +++ b/src/utils/bignumber.ts @@ -0,0 +1,41 @@ +import BigNumber from 'bignumber.js'; + +BigNumber.config({ + DECIMAL_PLACES: 20, + ROUNDING_MODE: BigNumber.ROUND_DOWN, +}); + +// Type representing values that can be converted to BN +export type BNValue = string | number | BigNumber; + +/** + * Factory function to create a BN instance safely + * - Accepts string, number, or BN instance + * - Avoids precision loss from floating point numbers + */ +export function bn(value: BNValue): BigNumber { + if (BigNumber.isBigNumber(value)) return value; + + if (typeof value === 'number') { + return new BigNumber(value.toString()); + } + const res = new BigNumber(value); + if (res.isNaN()) { + console.warn('Incorrect value, received', value); + return new BigNumber(0); + } + return res; +} + +// ------------------- Arithmetic operations ------------------- +export const add = (a: BNValue, b: BNValue): BigNumber => bn(a).plus(b); +export const sub = (a: BNValue, b: BNValue): BigNumber => bn(a).minus(b); +export const mul = (a: BNValue, b: BNValue): BigNumber => bn(a).multipliedBy(b); +export const div = (a: BNValue, b: BNValue): BigNumber => bn(a).dividedBy(b); + +// ------------------- Comparison operations ------------------- +export const eq = (a: BNValue, b: BNValue): boolean => bn(a).isEqualTo(b); +export const gt = (a: BNValue, b: BNValue): boolean => bn(a).isGreaterThan(b); +export const gte = (a: BNValue, b: BNValue): boolean => bn(a).isGreaterThanOrEqualTo(b); +export const lt = (a: BNValue, b: BNValue): boolean => bn(a).isLessThan(b); +export const lte = (a: BNValue, b: BNValue): boolean => bn(a).isLessThanOrEqualTo(b); diff --git a/src/utils/format-amount.ts b/src/utils/format-amount.ts index f83b125..93fb302 100644 --- a/src/utils/format-amount.ts +++ b/src/utils/format-amount.ts @@ -1,13 +1,12 @@ +import BigNumber from 'bignumber.js'; + const formatAmount = ( - amount: number, - { - minimumFractionDigits = 2, - maximumFractionDigits = 2, - }: { minimumFractionDigits?: number; maximumFractionDigits?: number } = {} -) => - amount.toLocaleString('en-US', { - minimumFractionDigits, - maximumFractionDigits, - }); + amount: BigNumber, + decimalPlaces: number = 2, + roundingMode: BigNumber.RoundingMode = BigNumber.ROUND_HALF_UP, + format?: BigNumber.Format +): string => { + return amount.toFormat(decimalPlaces, roundingMode, format); +}; export default formatAmount; diff --git a/src/utils/format-token-amount.ts b/src/utils/format-token-amount.ts index 34120d4..4e5ad72 100644 --- a/src/utils/format-token-amount.ts +++ b/src/utils/format-token-amount.ts @@ -1,20 +1,22 @@ import { AssetTicker } from '@tetherto/wdk-react-native-provider'; import formatAmount from './format-amount'; import getDisplaySymbol from './get-display-symbol'; +import BigNumber from 'bignumber.js'; -const formatTokenAmount = (amount: number, token: AssetTicker, includeSymbol: boolean = true) => { - const symbol = getDisplaySymbol(token); +const formatTokenAmount = ( + amount: BigNumber, + token: AssetTicker, + includeSymbol: boolean = true +) => { + const suffix = includeSymbol ? ` ${getDisplaySymbol(token)}` : ''; - if (amount === 0) return `0.00${includeSymbol ? ` ${symbol}` : ''}`; + if (amount.isZero()) return `0.00${suffix}`; - let decimals = Math.max(Math.ceil(Math.abs(Math.log10(amount))), 2); + const decimals = Math.max(Math.ceil(Math.abs(Math.log10(amount.toNumber()))), 2); - const formattedAmount = formatAmount(amount, { - minimumFractionDigits: 0, - maximumFractionDigits: decimals, + return formatAmount(amount, decimals, BigNumber.ROUND_HALF_UP, { + suffix, }); - - return `${formattedAmount}${includeSymbol ? ` ${symbol}` : ''}`; }; export default formatTokenAmount; diff --git a/src/utils/format-usd-value.ts b/src/utils/format-usd-value.ts index 66e9db2..a598a61 100644 --- a/src/utils/format-usd-value.ts +++ b/src/utils/format-usd-value.ts @@ -1,9 +1,20 @@ import formatAmount from './format-amount'; +import BigNumber from 'bignumber.js'; -const formatUSDValue = (usdValue: number, includeSymbol: boolean = true): string => { - if (usdValue === 0) return `0.00${includeSymbol ? ' USD' : ''}`; - if (usdValue < 0.01) return `< 0.01${includeSymbol ? ' USD' : ''}`; - return `${formatAmount(usdValue)}${includeSymbol ? ' USD' : ''}`; +const formatUSDValue = (usdValue: BigNumber, includeSymbol: boolean = true): string => { + const suffix = includeSymbol ? ' USD' : ''; + + if (usdValue.isZero()) { + return `0.00${suffix}`; + } + + if (usdValue.lt(0.01)) { + return `< 0.01${suffix}`; + } + + return formatAmount(usdValue, 2, BigNumber.ROUND_HALF_UP, { + suffix, + }); }; export default formatUSDValue; diff --git a/src/utils/gas-fee-calculator.ts b/src/utils/gas-fee-calculator.ts index 9b1ece5..8a01a80 100644 --- a/src/utils/gas-fee-calculator.ts +++ b/src/utils/gas-fee-calculator.ts @@ -1,7 +1,8 @@ import { AssetTicker, NetworkType, WDKService } from '@tetherto/wdk-react-native-provider'; +import { bn, lte, type BNValue } from '@/utils/bignumber'; export interface GasFeeEstimate { - fee?: number; + fee?: BigNumber; error?: string; } @@ -63,13 +64,14 @@ export const getAssetTicker = (tokenId: string): AssetTicker => { export const calculateGasFee = async ( networkId: string, tokenId: string, - amount?: number + amount?: BNValue ): Promise => { try { const networkType = getNetworkType(networkId); const assetTicker = getAssetTicker(tokenId); // @ts-expect-error const quoteRecipient = QUOTE_RECIPIENTS[assetTicker].networks[networkType]; + const amountBn = amount === undefined ? undefined : bn(amount); if (!amount && networkType === NetworkType.BITCOIN) { return { @@ -78,15 +80,21 @@ export const calculateGasFee = async ( }; } + const quoteAmountBn = + assetTicker === AssetTicker.BTC + ? bn(amountBn!).decimalPlaces(8, BigNumber.ROUND_DOWN) + : bn(1); + const quoteAmountForSdk = quoteAmountBn.toNumber(); + const gasFee = await WDKService.quoteSendByNetwork( networkType, 0, // account index - assetTicker === AssetTicker.BTC ? parseFloat(amount!.toFixed(8)) : 1, + quoteAmountForSdk, quoteRecipient, assetTicker ); - return { fee: gasFee }; + return { fee: bn(gasFee as any) }; } catch (error) { console.error('Gas fee pre-calculation failed:', error); const networkType = getNetworkType(networkId); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..760cf68 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,10 @@ +export * from './address-validators'; +export * from './bignumber'; +export { default as formatAmount } from './format-amount'; +export { default as formatTokenAmount } from './format-token-amount'; +export { default as formatUSDValue } from './format-usd-value'; +export * from './gas-fee-calculator'; +export { default as getDisplaySymbol } from './get-display-symbol'; +export { default as getErrorMessage } from './get-error-message'; +export { default as parseWorkletError } from './parse-worklet-error'; +export * from './recent-tokens'; diff --git a/tsconfig.json b/tsconfig.json index db476f3..2132557 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { + "baseUrl": ".", "strict": true, "types": ["jest", "node"], "paths": {