From b1cfd07b16d1ff13f8cd39652457f5596128bdd1 Mon Sep 17 00:00:00 2001 From: iGroza Date: Tue, 20 Aug 2024 11:07:04 +0700 Subject: [PATCH] feat(HW-463): swap layout --- .../json-rpc-common-transaction.tsx | 252 ++++++++++ .../json-rpc-sign-info.tsx | 8 +- .../{ => json-rpc-sign}/json-rpc-sign.tsx | 6 +- .../json-rpc-swap-transaction.tsx | 438 ++++++++++++++++++ .../json-rpc-transaction-info.tsx | 194 ++++++++ src/components/json-rpc-transaction-info.tsx | 335 -------------- src/components/swap/swap-route-path-icons.tsx | 45 +- src/components/swap/swap.tsx | 12 +- src/models/tokens.ts | 7 + .../json-rpc-sign-screen.tsx | 7 +- src/screens/SwapStack/swap-screen.tsx | 43 +- src/utils.ts | 40 +- 12 files changed, 1006 insertions(+), 381 deletions(-) create mode 100644 src/components/json-rpc-sign/json-rpc-common-transaction.tsx rename src/components/{ => json-rpc-sign}/json-rpc-sign-info.tsx (97%) rename src/components/{ => json-rpc-sign}/json-rpc-sign.tsx (91%) create mode 100644 src/components/json-rpc-sign/json-rpc-swap-transaction.tsx create mode 100644 src/components/json-rpc-sign/json-rpc-transaction-info.tsx delete mode 100644 src/components/json-rpc-transaction-info.tsx diff --git a/src/components/json-rpc-sign/json-rpc-common-transaction.tsx b/src/components/json-rpc-sign/json-rpc-common-transaction.tsx new file mode 100644 index 000000000..0dfd30d46 --- /dev/null +++ b/src/components/json-rpc-sign/json-rpc-common-transaction.tsx @@ -0,0 +1,252 @@ +import React, {useCallback, useMemo} from 'react'; + +import {ethers} from 'ethers'; +import {ActivityIndicator, ScrollView, View} from 'react-native'; +import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; + +import {Color} from '@app/colors'; +import { + DataView, + First, + Icon, + IconsName, + InfoBlock, + Spacer, + Text, + TextVariant, +} from '@app/components/ui'; +import {createTheme} from '@app/helpers'; +import {shortAddress} from '@app/helpers/short-address'; +import {I18N} from '@app/i18n'; +import {Contracts} from '@app/models/contracts'; +import {Fee} from '@app/models/fee'; +import {Provider} from '@app/models/provider'; +import {Token} from '@app/models/tokens'; +import {Balance} from '@app/services/balance'; +import {JsonRpcMetadata, JsonRpcTransactionRequest} from '@app/types'; +import {getHostnameFromUrl, openInAppBrowser} from '@app/utils'; +import {STRINGS} from '@app/variables/common'; + +import {SiteIconPreview, SiteIconPreviewSize} from '../site-icon-preview'; + +export interface JsonRpcCommonTransactionProps { + metadata: JsonRpcMetadata; + showSignContratAttention: boolean; + functionName?: string; + isContract: boolean; + provider: Provider | undefined; + isFeeLoading: boolean; + fee: Fee | null | undefined; + tx: Partial | undefined; + parsedInput: ethers.utils.TransactionDescription | undefined; + + onFeePress: () => void; +} + +export const JsonRpcCommonTransaction = ({ + metadata, + showSignContratAttention, + functionName, + isContract, + provider, + isFeeLoading, + fee, + tx, + parsedInput, + onFeePress, +}: JsonRpcCommonTransactionProps) => { + const url = useMemo(() => getHostnameFromUrl(metadata?.url), [metadata]); + const value = useMemo(() => { + if (functionName === 'approve') { + const token = Token.getById(tx?.to!); + return new Balance( + parsedInput?.args?.[1] || '0x0', + token.decimals!, + token.symbol!, + ); + } + + if (!tx?.value) { + return Balance.Empty; + } + + return new Balance(tx.value, provider?.decimals, provider?.denom); + }, [tx, provider, parsedInput]); + + const total = useMemo(() => { + if (functionName === 'approve') { + return fee?.calculatedFees?.expectedFee.toBalanceString('auto'); + } + return value + .operate(fee?.calculatedFees?.expectedFee ?? Balance.Empty, 'add') + .toBalanceString('auto'); + }, [value, fee?.calculatedFees?.expectedFee]); + + const onPressToAddress = useCallback(() => { + openInAppBrowser(provider?.getAddressExplorerUrl?.(tx?.to!)!); + }, [provider, tx]); + + const onPressApproveSpender = useCallback(() => { + openInAppBrowser( + provider?.getAddressExplorerUrl?.(parsedInput?.args?.[0])!, + ); + }, [provider, tx, parsedInput]); + + return ( + + + + + + + + + + {url} + + + + + + {showSignContratAttention && ( + <> + } + i18n={I18N.signContratAttention} + style={styles.signContractAttention} + /> + + + )} + + {functionName !== 'approve' && ( + <> + + + + {total} + + + + )} + + + + + + + {tx?.to} + + + + + + + + {functionName?.length ? ( + + ) : ( + + )} + + + {functionName === 'approve' && ( + + + {Contracts.getById(parsedInput?.args?.[0])?.name ?? ''} + {STRINGS.NBSP} + {shortAddress(tx?.to!, '•', true)} + + + )} + {!!provider?.id && ( + + + {provider.name} + + + )} + + + + + + {isFeeLoading && } + + + + {fee?.expectedFeeString} + + + + + + + + + + ); +}; + +const styles = createTheme({ + info: { + width: '100%', + borderRadius: 16, + backgroundColor: Color.bg3, + }, + fromContainer: { + flexDirection: 'row', + }, + fromImage: { + marginHorizontal: 4, + }, + signContractAttention: { + width: '100%', + }, + container: { + flex: 1, + width: '100%', + alignItems: 'center', + }, + feeContainer: { + flexDirection: 'row', + }, +}); diff --git a/src/components/json-rpc-sign-info.tsx b/src/components/json-rpc-sign/json-rpc-sign-info.tsx similarity index 97% rename from src/components/json-rpc-sign-info.tsx rename to src/components/json-rpc-sign/json-rpc-sign-info.tsx index 126dedf69..4f00d67d8 100644 --- a/src/components/json-rpc-sign-info.tsx +++ b/src/components/json-rpc-sign/json-rpc-sign-info.tsx @@ -30,10 +30,10 @@ import { } from '@app/utils'; import {EIP155_SIGNING_METHODS} from '@app/variables/EIP155'; -import {JsonViewer} from './json-viewer'; -import {SiteIconPreview, SiteIconPreviewSize} from './site-icon-preview'; -import {TypedDataViewer} from './typed-data-viewer'; -import {WalletRow, WalletRowTypes} from './wallet-row'; +import {JsonViewer} from '../json-viewer'; +import {SiteIconPreview, SiteIconPreviewSize} from '../site-icon-preview'; +import {TypedDataViewer} from '../typed-data-viewer'; +import {WalletRow, WalletRowTypes} from '../wallet-row'; interface WalletConnectSignInfoProps { request: PartialJsonRpcRequest; diff --git a/src/components/json-rpc-sign.tsx b/src/components/json-rpc-sign/json-rpc-sign.tsx similarity index 91% rename from src/components/json-rpc-sign.tsx rename to src/components/json-rpc-sign/json-rpc-sign.tsx index 145dc7740..98b01c08d 100644 --- a/src/components/json-rpc-sign.tsx +++ b/src/components/json-rpc-sign/json-rpc-sign.tsx @@ -5,8 +5,8 @@ import {View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {Color} from '@app/colors'; -import {JsonRpcSignInfo} from '@app/components/json-rpc-sign-info'; -import {JsonRpcTransactionInfo} from '@app/components/json-rpc-transaction-info'; +import {JsonRpcSignInfo} from '@app/components/json-rpc-sign/json-rpc-sign-info'; +import {JsonRpcTransactionInfo} from '@app/components/json-rpc-sign/json-rpc-transaction-info'; import {Button, ButtonVariant, Spacer} from '@app/components/ui'; import {createTheme} from '@app/helpers'; import {EthereumSignInMessage} from '@app/helpers/ethereum-message-checker'; @@ -96,6 +96,7 @@ export const JsonRpcSign = ({ {isTransaction && ( + // it renderns common transaction component for all transactions and custom uni/suhi swap transactions | undefined; + parsedInput: ethers.utils.TransactionDescription | undefined; + chainId: string | number; + verifyAddressResponse: VerifyAddressResponse | null; + + onFeePress: () => void; + onError: () => void; +} + +type Token = { + address: string; + amount: Balance; + image: ImageSourcePropType; +}; + +export const JsonRpcSwapTransaction = observer( + ({ + provider, + isFeeLoading, + fee, + functionName, + parsedInput, + chainId, + tx, + onFeePress, + onError, + }: JsonRpcSwapTransactionProps) => { + const [estimateData, setEstimateData] = + useState(null); + const [isLoading, setIsLoading] = useState(true); + + const [tokenIn, setTokenIn] = useState(null); + const [tokenOut, setTokenOut] = useState(null); + const [minReceivedAmount, setMinReceivedAmount] = useState( + null, + ); + + const rate = useMemo(() => { + const r = + (tokenOut?.amount?.toFloat() ?? 0) / + new Balance( + estimateData?.amount_in!, + tokenIn?.amount.getPrecission(), + tokenIn?.amount.getSymbol(), + ).toFloat(); + return new Balance(r, 0, tokenOut?.amount.getSymbol()).toBalanceString( + 'auto', + ); + }, [tokenOut, tokenIn, estimateData]); + + const priceImpactColor = useMemo(() => { + if (!estimateData?.s_price_impact) { + return Color.textBase1; + } + + const PI = parseFloat(estimateData.s_price_impact); + + if (PI >= 5) { + return Color.textRed1; + } + + if (PI >= 1) { + return Color.textYellow1; + } + + return Color.textBase1; + }, [estimateData]); + + const providerFee = useMemo(() => { + const symbol = tokenIn?.amount?.getSymbol()!; + const decimals = tokenIn?.amount?.getPrecission()!; + if (!estimateData?.fee) { + return new Balance(Balance.Empty, decimals, symbol); + } + return new Balance(estimateData?.fee.amount || '0', decimals, symbol); + }, [tokenIn, estimateData]); + + const onPressRoutingSource = useCallback(() => { + openInAppBrowser(provider?.getAddressExplorerUrl?.(tx?.to!)!); + }, [provider, tx]); + + useEffectAsync(async () => { + try { + const indexer = new Indexer(chainId); + let amountOutMinimum = '0x0', + tokenInAddress = '', + tokenOutAddress = '', + response: SushiPoolEstimateResponse | null; + + if (functionName === 'exactInput') { + const [path, recipient, _, amountIn, _amountOutMinimum] = parsedInput + ?.args[0]! as [ + string, // path + string, // recipient + number, // deadline + BigNumber, // amountIn + BigNumber, // amountOutMinimum + ]; + amountOutMinimum = _amountOutMinimum._hex; + const matchArray = path.match( + /^0x([a-fA-F0-9]{40}).*([a-fA-F0-9]{40})$/, + ); + + // first 40 characters of path doesn't include the '0x' prefix + tokenInAddress = `0x${matchArray?.[1]}`; + // last 40 characters of path + tokenOutAddress = `0x${matchArray?.[2]}`; + + response = await indexer.sushiPoolEstimate({ + amount: amountIn._hex, + sender: recipient, + route: path.slice(2), + currency_id: Currencies.currency?.id, + }); + } + + if (functionName === 'exactInput') { + const [path, recipient, _, amountIn, _amountOutMinimum] = parsedInput + ?.args[0]! as [ + string, // path + string, // recipient + number, // deadline + BigNumber, // amountIn + BigNumber, // amountOutMinimum + ]; + amountOutMinimum = _amountOutMinimum._hex; + const matchArray = path.match( + /^0x([a-fA-F0-9]{40}).*([a-fA-F0-9]{40})$/, + ); + + // first 40 characters of path doesn't include the '0x' prefix + tokenInAddress = `0x${matchArray?.[1]}`; + // last 40 characters of path + tokenOutAddress = `0x${matchArray?.[2]}`; + + response = await indexer.sushiPoolEstimate({ + amount: amountIn._hex, + sender: recipient, + route: path.slice(2), + currency_id: Currencies.currency?.id, + }); + } + + // unwrap + if (functionName === 'withdraw') { + const config = await indexer.getProviderConfig(); + tokenInAddress = config.weth_address; + tokenOutAddress = NATIVE_TOKEN_ADDRESS; + amountOutMinimum = parsedInput?.args[0]; + + response = { + allowance: '0x0', + amount_in: parsedInput?.args[0], + amount_out: parsedInput?.args[0], + fee: { + amount: '0', + denom: Currencies.currency?.id!, + }, + gas_estimate: '0x0', + initialized_ticks_crossed_list: [0], + need_approve: false, + route: `${tokenInAddress}000000${tokenOutAddress.slice(2)}`, + s_amount_in: '0x0', + s_assumed_amount_out: '0x0', + s_gas_spent: 0, + s_price_impact: '0', + s_primary_price: '0', + s_swap_price: '0', + sqrt_price_x96_after_list: [], + }; + } + + // wrap + if (functionName === 'deposit') { + const config = await indexer.getProviderConfig(); + tokenInAddress = NATIVE_TOKEN_ADDRESS; + tokenOutAddress = config.weth_address; + amountOutMinimum = tx?.value!; + + response = { + allowance: '0x0', + amount_in: tx?.value!, + amount_out: tx?.value!, + fee: { + amount: '0', + denom: Currencies.currency?.id!, + }, + gas_estimate: '0x0', + initialized_ticks_crossed_list: [0], + need_approve: false, + route: `${tokenInAddress}000000${tokenOutAddress.slice(2)}`, + s_amount_in: '0x0', + s_assumed_amount_out: '0x0', + s_gas_spent: 0, + s_price_impact: '0', + s_primary_price: '0', + s_swap_price: '0', + sqrt_price_x96_after_list: [], + }; + } + + const recipientWallet = Wallet.getById(tx?.from)!; + const tokenInIsNativeCoin = new Balance(tx?.value!).isPositive(); + const tokenInContract = tokenInIsNativeCoin + ? Token.generateNativeToken(recipientWallet) + : Token.getById(tokenInAddress)!; + + const tokenOutContract = Token.getById(tokenOutAddress)!; + + setTokenIn(() => ({ + address: tokenInAddress, + amount: new Balance( + response?.amount_in!, + tokenInContract?.decimals!, + tokenInContract?.symbol!, + ), + image: tokenInContract.image, + })); + + setTokenOut(() => ({ + address: tokenOutAddress, + amount: new Balance( + response?.amount_out!, + tokenOutContract?.decimals!, + tokenOutContract?.symbol!, + ), + image: tokenOutContract.image, + })); + + setMinReceivedAmount( + new Balance( + amountOutMinimum, + tokenOutContract?.decimals!, + tokenOutContract?.symbol!, + ), + ); + + setEstimateData(() => response); + setIsLoading(() => false); + } catch (err) { + onError?.(); + } + }, [onError, chainId]); + + if (isLoading) { + return ; + } + + return ( + + + + + + + + + {tokenIn?.amount.toBalanceString('auto')} + + + + ≈{STRINGS.NBSP} + {tokenIn?.amount.toFiat({useDefaultCurrency: true, fixed: 6})} + + + + + + + + + + + + {tokenOut?.amount.toBalanceString('auto')} + + + + ≈{STRINGS.NBSP} + {tokenOut?.amount.toFiat({useDefaultCurrency: true, fixed: 6})} + + + + + + + + {`1${STRINGS.NBSP}${tokenIn?.amount?.getSymbol()}${ + STRINGS.NBSP + }≈${STRINGS.NBSP}${rate}`} + + + + + {minReceivedAmount?.toBalanceString('auto')} + + + + + {`${formatNumberString(estimateData?.s_price_impact ?? '0')}%`} + + + + + {providerFee.toFiat({useDefaultCurrency: true, fixed: 6})} + + + {!!provider?.id && ( + + + {provider.name} + + + )} + + + {isFeeLoading && } + + + + {fee?.expectedFeeString} + + + + + + + + + {Contracts.getById(tx?.to!)?.name} + {STRINGS.NBSP} + {shortAddress(tx?.to!, '•', true)} + + + + + + + + + ); + }, +); + +const styles = createTheme({ + info: { + width: '100%', + borderRadius: 16, + backgroundColor: Color.bg3, + }, + container: { + flex: 1, + width: '100%', + alignItems: 'center', + }, + feeContainer: { + flexDirection: 'row', + }, + tokenContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + tokenImage: { + width: 24, + height: 24, + borderRadius: 12, + }, +}); diff --git a/src/components/json-rpc-sign/json-rpc-transaction-info.tsx b/src/components/json-rpc-sign/json-rpc-transaction-info.tsx new file mode 100644 index 000000000..6435c86d7 --- /dev/null +++ b/src/components/json-rpc-sign/json-rpc-transaction-info.tsx @@ -0,0 +1,194 @@ +import React, {useCallback, useMemo, useState} from 'react'; + +import {app} from '@app/contexts'; +import {awaitForFee} from '@app/helpers/await-for-fee'; +import {useTypedNavigation} from '@app/hooks'; +import {useEffectAsync} from '@app/hooks/use-effect-async'; +import {EstimationVariant, Fee} from '@app/models/fee'; +import {Provider} from '@app/models/provider'; +import {JsonRpcSignPopupStackParamList} from '@app/route-types'; +import {EthNetwork} from '@app/services'; +import {Balance} from '@app/services/balance'; +import { + AddressType, + JsonRpcMetadata, + PartialJsonRpcRequest, + VerifyAddressResponse, +} from '@app/types'; +import { + getTransactionFromJsonRpcRequest, + isContractTransaction, + parseTxDataFromHexInput, +} from '@app/utils'; + +import {JsonRpcCommonTransaction} from './json-rpc-common-transaction'; +import {JsonRpcSwapTransaction} from './json-rpc-swap-transaction'; + +import {First} from '../ui'; + +interface JsonRpcTransactionInfoProps { + request: PartialJsonRpcRequest; + metadata: JsonRpcMetadata; + verifyAddressResponse: VerifyAddressResponse | null; + chainId?: number; + hideContractAttention?: boolean; + fee?: Fee | null; + setFee: React.Dispatch>; +} + +export const JsonRpcTransactionInfo = ({ + request, + metadata, + verifyAddressResponse, + chainId, + hideContractAttention, + fee, + setFee, +}: JsonRpcTransactionInfoProps) => { + const navigation = useTypedNavigation(); + + const [isFeeLoading, setFeeLoading] = useState(true); + + const tx = useMemo( + () => getTransactionFromJsonRpcRequest(request), + [request], + ); + + const provider = useMemo(() => { + return Provider.getByEthChainId( + tx?.chainId ?? chainId ?? app.provider.ethChainId, + ); + }, [chainId, tx]); + + const isContract = useMemo( + () => + verifyAddressResponse?.address_type === AddressType.contract || + isContractTransaction(tx), + [tx, verifyAddressResponse], + ); + + const isInWhiteList = useMemo( + () => !!verifyAddressResponse?.is_in_white_list, + [verifyAddressResponse], + ); + + const showSignContratAttention = + !hideContractAttention && isContract && !isInWhiteList; + + const txParsedData = useMemo(() => parseTxDataFromHexInput(tx?.data), [tx]); + + const functionName = useMemo(() => { + if (txParsedData) { + return txParsedData.name; + } + return ''; + }, [txParsedData]); + + const isSwapTx = useMemo( + () => ['exactInput', 'deposit', 'withdraw'].includes(functionName), + [functionName], + ); + + const calculateFee = useCallback(async () => { + if (!tx) { + return Balance.Empty; + } + + try { + const data = await EthNetwork.estimate( + { + from: tx.from!, + to: tx.to!, + value: new Balance( + tx.value || Balance.Empty, + provider?.decimals, + provider?.denom, + ), + data: tx.data, + }, + EstimationVariant.average, + provider, + ); + setFee(new Fee(data)); + return data.expectedFee; + } catch { + return Balance.Empty; + } + }, [tx, provider]); + + const onFeePress = useCallback(async () => { + if (!tx) { + return; + } + + if (fee) { + const result = await awaitForFee({ + fee, + from: tx.from!, + to: tx.to!, + value: new Balance( + tx.value! || Balance.Empty, + provider?.decimals, + provider?.denom, + ), + data: tx.data, + chainId: provider?.ethChainId ? String(provider.ethChainId) : undefined, + }); + setFee(result); + } + }, [navigation, tx, fee, provider]); + + const onSwapRenderError = useCallback(() => {}, []); + + useEffectAsync(async () => { + if (!fee?.calculatedFees) { + try { + if (tx) { + setFeeLoading(true); + await calculateFee(); + } + } catch (err) { + Logger.captureException(err, 'JsonRpcTransactionInfo:calculateFee', { + params: tx, + chainId, + }); + } finally { + setFeeLoading(false); + } + } else { + setFeeLoading(false); + } + }, [chainId]); + + return ( + + {isSwapTx && isContract && isInWhiteList && ( + + )} + + + ); +}; diff --git a/src/components/json-rpc-transaction-info.tsx b/src/components/json-rpc-transaction-info.tsx deleted file mode 100644 index 223b4a228..000000000 --- a/src/components/json-rpc-transaction-info.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import React, {useCallback, useMemo, useState} from 'react'; - -import {ActivityIndicator, ScrollView, View} from 'react-native'; -import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; - -import {Color} from '@app/colors'; -import { - DataView, - First, - Icon, - IconsName, - InfoBlock, - Spacer, - Text, - TextVariant, -} from '@app/components/ui'; -import {app} from '@app/contexts'; -import {createTheme} from '@app/helpers'; -import {awaitForFee} from '@app/helpers/await-for-fee'; -import {useTypedNavigation} from '@app/hooks'; -import {useEffectAsync} from '@app/hooks/use-effect-async'; -import {I18N} from '@app/i18n'; -import {EstimationVariant, Fee} from '@app/models/fee'; -import {Provider} from '@app/models/provider'; -import {JsonRpcSignPopupStackParamList} from '@app/route-types'; -import {EthNetwork} from '@app/services'; -import {Balance} from '@app/services/balance'; -import { - AddressType, - JsonRpcMetadata, - PartialJsonRpcRequest, - VerifyAddressResponse, -} from '@app/types'; -import { - getHostnameFromUrl, - getTransactionFromJsonRpcRequest, - isContractTransaction, - parseERC20TxDataFromHexInput, -} from '@app/utils'; -import {LONG_NUM_PRECISION} from '@app/variables/common'; - -import {SiteIconPreview, SiteIconPreviewSize} from './site-icon-preview'; - -interface JsonRpcTransactionInfoProps { - request: PartialJsonRpcRequest; - metadata: JsonRpcMetadata; - verifyAddressResponse: VerifyAddressResponse | null; - chainId?: number; - hideContractAttention?: boolean; - fee?: Fee | null; - setFee: React.Dispatch>; -} - -export const JsonRpcTransactionInfo = ({ - request, - metadata, - verifyAddressResponse, - chainId, - hideContractAttention, - fee, - setFee, -}: JsonRpcTransactionInfoProps) => { - const navigation = useTypedNavigation(); - - const [isFeeLoading, setFeeLoading] = useState(true); - - const tx = useMemo( - () => getTransactionFromJsonRpcRequest(request), - [request], - ); - - const provider = useMemo(() => { - return Provider.getByEthChainId( - tx?.chainId ?? chainId ?? app.provider.ethChainId, - ); - }, [chainId, tx]); - - const url = useMemo(() => getHostnameFromUrl(metadata?.url), [metadata]); - - const value = useMemo(() => { - if (!tx?.value) { - return Balance.Empty; - } - - return new Balance(tx.value, provider?.decimals, provider?.denom); - }, [tx, provider]); - - const calculateFee = useCallback(async () => { - if (!tx) { - return Balance.Empty; - } - - try { - const data = await EthNetwork.estimate( - { - from: tx.from!, - to: tx.to!, - value: new Balance( - tx.value || Balance.Empty, - provider?.decimals, - provider?.denom, - ), - data: tx.data, - }, - EstimationVariant.average, - provider, - ); - setFee(new Fee(data)); - return data.expectedFee; - } catch { - return Balance.Empty; - } - }, [tx, provider]); - - const total = useMemo(() => { - const float = value.toFloat(); - const fixedNum = float >= 1 ? 3 : LONG_NUM_PRECISION; - return value - .operate(fee?.calculatedFees?.expectedFee ?? Balance.Empty, 'add') - .toBalanceString(fixedNum); - }, [value, fee?.calculatedFees?.expectedFee]); - - const isContract = useMemo( - () => - verifyAddressResponse?.address_type === AddressType.contract || - isContractTransaction(tx), - [tx, verifyAddressResponse], - ); - - const isInWhiteList = useMemo( - () => !!verifyAddressResponse?.is_in_white_list, - [verifyAddressResponse], - ); - - const showSignContratAttention = - !hideContractAttention && isContract && !isInWhiteList; - - useEffectAsync(async () => { - if (!fee?.calculatedFees) { - try { - if (tx) { - setFeeLoading(true); - await calculateFee(); - } - } catch (err) { - Logger.captureException(err, 'JsonRpcTransactionInfo:calculateFee', { - params: tx, - chainId, - }); - } finally { - setFeeLoading(false); - } - } else { - setFeeLoading(false); - } - }, [chainId]); - - const txParsedData = useMemo( - () => parseERC20TxDataFromHexInput(tx?.data), - [tx], - ); - - const functionName = useMemo(() => { - if (txParsedData) { - return txParsedData.name; - } - return ''; - }, [txParsedData]); - - const onFeePress = useCallback(async () => { - if (!tx) { - return; - } - - if (fee) { - const result = await awaitForFee({ - fee, - from: tx.from!, - to: tx.to!, - value: new Balance( - tx.value! || Balance.Empty, - provider?.decimals, - provider?.denom, - ), - data: tx.data, - chainId: provider?.ethChainId ? String(provider.ethChainId) : undefined, - }); - setFee(result); - } - }, [navigation, tx, fee, provider]); - - return ( - - - - - - - - - - {url} - - - - - - {showSignContratAttention && ( - <> - } - i18n={I18N.signContratAttention} - style={styles.signContractAttention} - /> - - - )} - - - - - - {total} - - - - - - - - - {tx?.to} - - - - - - - - {functionName?.length ? ( - - ) : ( - - )} - - - - - {`${provider?.coinName} ${provider?.denom}`} - - - {!!provider?.id && ( - - - {provider.name} - - - )} - - - - - - {isFeeLoading && } - - - - {fee?.expectedFeeString} - - - - - - - - - - ); -}; - -const styles = createTheme({ - info: { - width: '100%', - borderRadius: 16, - backgroundColor: Color.bg3, - }, - fromContainer: { - flexDirection: 'row', - }, - fromImage: { - marginHorizontal: 4, - }, - signContractAttention: { - width: '100%', - }, - container: { - flex: 1, - width: '100%', - alignItems: 'center', - }, - feeContainer: { - flexDirection: 'row', - }, -}); diff --git a/src/components/swap/swap-route-path-icons.tsx b/src/components/swap/swap-route-path-icons.tsx index e9bed90e1..2b64ebe33 100644 --- a/src/components/swap/swap-route-path-icons.tsx +++ b/src/components/swap/swap-route-path-icons.tsx @@ -12,15 +12,52 @@ import {STRINGS} from '@app/variables/common'; import {ImageWrapper} from '../image-wrapper'; import {Text, TextVariant} from '../ui'; -export interface SwapRoutePathIconsProps { - route: HaqqCosmosAddress[]; +export enum SwapRoutePathIconsType { + route, + path, +} + +export type SwapRoutePathIconsProps = + | { + type: SwapRoutePathIconsType; + route: HaqqCosmosAddress[]; + } + | { + type: SwapRoutePathIconsType; + hexPath: string; + }; + +function decodeSwapPath(encodedPath: string) { + // Ensure the path starts with '0x' and remove it + let path = encodedPath.startsWith('0x') ? encodedPath.slice(2) : encodedPath; + + const addresses = []; + + // The first token address (20 bytes = 40 hex chars) + addresses.push(`0x${path.slice(0, 40)}`); + path = path.slice(40); + + // Loop through the path in chunks of 46 characters (6 for fee, 40 for token address) + while (path.length >= 46) { + const tokenAddress = `0x${path.slice(6, 46)}`; // Ignore the first 6 chars (fee), take next 40 + addresses.push(tokenAddress); + path = path.slice(46); // Move to the next chunk + } + + return addresses; } export const SwapRoutePathIcons = observer( - ({route}: SwapRoutePathIconsProps) => { + // @ts-ignore + ({route, hexPath, type}: SwapRoutePathIconsProps) => { + const adresses = + type === SwapRoutePathIconsType.path + ? decodeSwapPath(hexPath) + : (route as string[]); + return ( - {route.map((address, i, arr) => { + {adresses.map((address, i, arr) => { const contract = Contracts.getById(address); const isLast = arr.length - 1 === i; diff --git a/src/components/swap/swap.tsx b/src/components/swap/swap.tsx index 4b1048ab3..a0c53f059 100644 --- a/src/components/swap/swap.tsx +++ b/src/components/swap/swap.tsx @@ -21,7 +21,10 @@ import {STRINGS} from '@app/variables/common'; import {EstimatedValue} from './estimated-value'; import {SwapInput} from './swap-input'; -import {SwapRoutePathIcons} from './swap-route-path-icons'; +import { + SwapRoutePathIcons, + SwapRoutePathIconsType, +} from './swap-route-path-icons'; import { SwapSettingBottomSheet, SwapSettingBottomSheetRef, @@ -243,7 +246,12 @@ export const Swap = observer( } + value={ + + } /> )} diff --git a/src/models/tokens.ts b/src/models/tokens.ts index 2367f690c..760ae5ef6 100644 --- a/src/models/tokens.ts +++ b/src/models/tokens.ts @@ -190,6 +190,7 @@ class TokensStore implements MobXStore { return true; } + _lastFetchedTimestamp = 0; fetchTokens = async ( force = true, fetchTokensFromRPC = DEBUG_VARS.enableHardcodeERC20TokensContract, @@ -198,6 +199,12 @@ class TokensStore implements MobXStore { return; } + const currentTime = Date.now(); + if (currentTime - this._lastFetchedTimestamp < 10_000 && !force) { + return; + } + this._lastFetchedTimestamp = currentTime; + runInAction(() => { this._isLoading = true; }); diff --git a/src/screens/HomeStack/JsonRpcSignPopupStack/json-rpc-sign-screen.tsx b/src/screens/HomeStack/JsonRpcSignPopupStack/json-rpc-sign-screen.tsx index 1d34c8262..3560e5c03 100644 --- a/src/screens/HomeStack/JsonRpcSignPopupStack/json-rpc-sign-screen.tsx +++ b/src/screens/HomeStack/JsonRpcSignPopupStack/json-rpc-sign-screen.tsx @@ -2,12 +2,13 @@ import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {ethers} from 'ethers'; -import {JsonRpcSign} from '@app/components/json-rpc-sign'; -import {getMessageByRequest} from '@app/components/json-rpc-sign-info'; +import {JsonRpcSign} from '@app/components/json-rpc-sign/json-rpc-sign'; +import {getMessageByRequest} from '@app/components/json-rpc-sign/json-rpc-sign-info'; import {Loading} from '@app/components/ui'; import {app} from '@app/contexts'; import {DEBUG_VARS} from '@app/debug-vars'; import {ModalStore, showModal} from '@app/helpers'; +import {AddressUtils} from '@app/helpers/address-utils'; import { EthereumMessageChecker, EthereumSignInMessage, @@ -198,7 +199,7 @@ export const JsonRpcSignScreen = memo(() => { } = await indexer.verifyContract({ domain: metadata.url, method_name: request.method, - address: toAddress, + address: AddressUtils.toHaqq(toAddress!), message_or_input, }); diff --git a/src/screens/SwapStack/swap-screen.tsx b/src/screens/SwapStack/swap-screen.tsx index e35f84976..358939dc8 100644 --- a/src/screens/SwapStack/swap-screen.tsx +++ b/src/screens/SwapStack/swap-screen.tsx @@ -88,8 +88,8 @@ export const SwapScreen = observer(() => { routes: [], pools: [], }); - const routesByToken0 = useRef>({}); - const routesByToken1 = useRef>({}); + const routesByToken0 = useRef>({}); + const routesByToken1 = useRef>({}); const [estimateData, setEstimateData] = useState(null); @@ -151,8 +151,11 @@ export const SwapScreen = observer(() => { const slippage = parseFloat(swapSettings.slippage) / 100; const amountOut = parseFloat(amountsOut.amount); - const result = (amountOut - amountOut * slippage).toString(); - return new Balance(result, 0, tokenOut.symbol!); + const result = ( + (amountOut - amountOut * slippage) * + 10 ** tokenOut.decimals! + ).toString(); + return new Balance(result, tokenOut.decimals!, tokenOut.symbol!); }, [estimateData, tokenOut, swapSettings, amountsOut.amount]); const providerFee = useMemo(() => { @@ -355,7 +358,6 @@ export const SwapScreen = observer(() => { const awaitForToken = useCallback( async (initialValue: IToken) => { try { - logger.log('awaitForToken', Token.tokens); if (!Token.tokens?.[currentWallet.address]) { const hide = showModal(ModalType.loading, { text: 'Loading token balances', @@ -386,8 +388,8 @@ export const SwapScreen = observer(() => { }; const routes = isToken0 - ? routesByToken1.current[AddressUtils.toHaqq(initialValue.id!)] - : routesByToken0.current[AddressUtils.toHaqq(initialValue.id!)]; + ? routesByToken1.current[AddressUtils.toEth(initialValue.id!)] + : routesByToken0.current[AddressUtils.toEth(initialValue.id!)]; const possibleRoutesForSwap = routes .map(it => { @@ -777,7 +779,7 @@ export const SwapScreen = observer(() => { params: [ { from: currentWallet.address, - to: tokenIn?.id, + to: AddressUtils.toEth(tokenIn?.id!), value: '0x0', data: data, }, @@ -811,7 +813,7 @@ export const SwapScreen = observer(() => { setSwapInProgress(() => true); const provider = await getRpcProvider(app.provider); const WETH = new ethers.Contract( - RemoteProviderConfig.wethAddress, + AddressUtils.toEth(RemoteProviderConfig.wethAddress), WETH_ABI, provider, ); @@ -825,7 +827,7 @@ export const SwapScreen = observer(() => { params: [ { from: currentWallet.address, - to: RemoteProviderConfig.wethAddress, + to: AddressUtils.toEth(RemoteProviderConfig.wethAddress), value: t0Current.toHex(), data: txData, }, @@ -881,7 +883,7 @@ export const SwapScreen = observer(() => { setSwapInProgress(() => true); const provider = await getRpcProvider(app.provider); const WETH = new ethers.Contract( - RemoteProviderConfig.wethAddress, + AddressUtils.toEth(RemoteProviderConfig.wethAddress), WETH_ABI, provider, ); @@ -902,7 +904,7 @@ export const SwapScreen = observer(() => { params: [ { from: currentWallet.address, - to: RemoteProviderConfig.wethAddress, + to: AddressUtils.toEth(RemoteProviderConfig.wethAddress), value: '0x0', data: txData, }, @@ -1056,6 +1058,7 @@ export const SwapScreen = observer(() => { }) as IToken, ), ) + .concat([Token.generateNativeToken(currentWallet)!]) .filter(Boolean) as IToken[]; setPoolsData(() => ({ @@ -1066,15 +1069,19 @@ export const SwapScreen = observer(() => { routesByToken0.current = {}; routesByToken1.current = {}; data.routes.forEach(route => { - if (!routesByToken0.current[route.token0]) { - routesByToken0.current[route.token0] = []; + if (!routesByToken0.current[AddressUtils.toEth(route.token0)]) { + routesByToken0.current[AddressUtils.toEth(route.token0)] = []; } - routesByToken0.current[route.token0].push(route); + routesByToken0.current[AddressUtils.toEth(route.token0)].push( + route, + ); - if (!routesByToken1.current[route.token1]) { - routesByToken1.current[route.token1] = []; + if (!routesByToken1.current[AddressUtils.toEth(route.token1)]) { + routesByToken1.current[AddressUtils.toEth(route.token1)] = []; } - routesByToken1.current[route.token1].push(route); + routesByToken1.current[AddressUtils.toEth(route.token1)].push( + route, + ); }); setCurrentRoute( () => diff --git a/src/utils.ts b/src/utils.ts index 424ad43a5..5088c92df 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -59,7 +59,7 @@ import { SendTransactionError, WalletConnectParsedAccount, } from './types'; -import {ERC20_ABI} from './variables/abi'; +import {ERC20_ABI, V3SWAPROUTER_ABI, WETH_ABI} from './variables/abi'; import {IS_ANDROID, RTL_LANGUAGES, STORE_PAGE_URL} from './variables/common'; import {EIP155_SIGNING_METHODS} from './variables/EIP155'; @@ -992,20 +992,34 @@ export function applyEthTxMultiplier(toBalance: Balance) { ); } -export function parseERC20TxDataFromHexInput(hex?: string) { +export function parseTxDataFromHexInput(hex?: string) { + if (!hex) { + return undefined; + } + let data = hex; + if (!hex.startsWith('0x')) { + data = `0x${hex}`; + } + + // try to parse as a ERC20 contract call try { - if (!hex) { - return undefined; - } - let data = hex; - if (!hex.startsWith('0x')) { - data = `0x${hex}`; - } - if (data) { - const erc20Interface = new ethers.utils.Interface(ERC20_ABI); - return erc20Interface.parseTransaction({data: data}); - } + return new ethers.utils.Interface(ERC20_ABI).parseTransaction({data: data}); } catch (e) {} + + // try to parse as a swap transaction + try { + return new ethers.utils.Interface(V3SWAPROUTER_ABI).parseTransaction({ + data: data, + }); + } catch (e) {} + + // try to parse as a wrap/unwrap transaction + try { + return new ethers.utils.Interface(WETH_ABI).parseTransaction({ + data: data, + }); + } catch (e) {} + return undefined; }