From a8796d7176638f25ac60b87a853b0bf4439df04a Mon Sep 17 00:00:00 2001 From: mytonwalletorg Date: Fri, 7 Feb 2025 16:07:20 +0100 Subject: [PATCH] v3.3.2 --- changelogs/3.3.2.txt | 1 + package-lock.json | 4 +- package.json | 2 +- public/version.txt | 2 +- src/api/methods/swap.ts | 10 +- src/components/auth/AuthImportMnemonic.tsx | 7 +- .../common/FeeDetailsModal.module.scss | 2 +- .../explore/CategoryHeader.module.scss | 24 +- src/components/explore/Explore.module.scss | 8 +- src/components/explore/SiteList.module.scss | 6 +- .../sections/Actions/BottomBar.module.scss | 8 +- .../main/sections/Actions/BottomBar.tsx | 4 +- .../main/sections/Card/StickyCard.module.scss | 8 +- .../main/sections/Content/Content.module.scss | 22 +- .../Content/NftCollectionHeader.module.scss | 14 +- .../main/sections/Content/NftMenu.module.scss | 4 + .../main/sections/Content/NftMenu.tsx | 1 + src/components/settings/Settings.module.scss | 7 +- src/components/settings/Settings.tsx | 1 + src/components/staking/StakingInitial.tsx | 2 +- src/components/swap/SwapDexChooser.tsx | 14 +- src/components/swap/SwapInitial.tsx | 87 +-- src/components/swap/SwapModal.tsx | 5 +- src/components/swap/SwapSettingsModal.tsx | 6 +- src/components/transfer/TransferInitial.tsx | 53 +- src/components/ui/Dropdown.module.scss | 2 +- src/config.ts | 18 +- src/global/actions/api/swap.ts | 629 +++++++----------- src/global/actions/apiUpdates/dapp.ts | 2 +- src/global/actions/ui/misc.ts | 79 +-- src/global/actions/ui/transfer.ts | 28 +- src/global/cache.ts | 15 +- src/global/helpers/misc.ts | 37 ++ src/global/helpers/staking.ts | 11 +- src/global/helpers/swap.ts | 91 +++ src/global/reducers/swap.ts | 39 +- src/global/reducers/transfer.ts | 15 +- src/global/selectors/staking.ts | 7 +- src/global/selectors/swap.ts | 26 +- src/global/selectors/tokens.ts | 13 +- src/global/selectors/transfer.ts | 42 +- src/global/types.ts | 9 +- src/i18n/de.yaml | 23 +- src/i18n/en.yaml | 5 +- src/i18n/es.yaml | 5 +- src/i18n/pl.yaml | 9 +- src/i18n/ru.yaml | 9 +- src/i18n/th.yaml | 7 +- src/i18n/tr.yaml | 13 +- src/i18n/uk.yaml | 9 +- src/i18n/zh-Hans.yaml | 7 +- src/i18n/zh-Hant.yaml | 7 +- src/lib/teact/teactn.tsx | 1 + src/util/capacitor/index.ts | 2 +- src/util/decimals.ts | 4 - src/util/deeplink/index.ts | 135 +++- src/util/electron.ts | 2 +- src/util/fee/swapFee.ts | 6 +- src/util/isValidAddressOrDomain.ts | 7 +- 59 files changed, 815 insertions(+), 801 deletions(-) create mode 100644 changelogs/3.3.2.txt create mode 100644 src/global/helpers/misc.ts create mode 100644 src/global/helpers/swap.ts diff --git a/changelogs/3.3.2.txt b/changelogs/3.3.2.txt new file mode 100644 index 00000000..619f4cd5 --- /dev/null +++ b/changelogs/3.3.2.txt @@ -0,0 +1 @@ +Bug fixes and performance improvements diff --git a/package-lock.json b/package-lock.json index d9f7b743..ab6c54c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mytonwallet", - "version": "3.3.1", + "version": "3.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mytonwallet", - "version": "3.3.1", + "version": "3.3.2", "license": "GPL-3.0-or-later", "dependencies": { "@awesome-cordova-plugins/core": "6.9.0", diff --git a/package.json b/package.json index bf60fe59..0ceaf7d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mytonwallet", - "version": "3.3.1", + "version": "3.3.2", "description": "The most feature-rich web wallet and browser extension for TON – with support of multi-accounts, tokens (jettons), NFT, TON DNS, TON Sites, TON Proxy, and TON Magic.", "main": "index.js", "scripts": { diff --git a/public/version.txt b/public/version.txt index bea438e9..47725433 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -3.3.1 +3.3.2 diff --git a/src/api/methods/swap.ts b/src/api/methods/swap.ts index 5760a138..64c78ae4 100644 --- a/src/api/methods/swap.ts +++ b/src/api/methods/swap.ts @@ -222,8 +222,14 @@ export function swapGetPairs(symbolOrTokenAddress: string): Promise { - return callBackendPost('/swap/cex/estimate', request, { isAllowBadRequest: true }); +export function swapCexEstimate( + request: ApiSwapCexEstimateRequest, +): Promise { + return callBackendPost( + '/swap/cex/estimate', + request, + { isAllowBadRequest: true }, + ); } export function swapCexValidateAddress(params: { slug: string; address: string }): Promise<{ diff --git a/src/components/auth/AuthImportMnemonic.tsx b/src/components/auth/AuthImportMnemonic.tsx index dcae59dd..1cf85ec0 100644 --- a/src/components/auth/AuthImportMnemonic.tsx +++ b/src/components/auth/AuthImportMnemonic.tsx @@ -46,6 +46,7 @@ const AuthImportMnemonic = ({ isActive, isLoading, error }: OwnProps & StateProp const { afterImportMnemonic, resetAuth, + cleanAuthError, } = getActions(); const lang = useLang(); @@ -87,10 +88,12 @@ const AuthImportMnemonic = ({ isActive, isLoading, error }: OwnProps & StateProp const isSubmitDisabled = useMemo(() => { const mnemonicValues = compact(Object.values(mnemonic)); - return !MNEMONIC_COUNTS.includes(mnemonicValues.length) && !isMnemonicPrivateKey(mnemonicValues); - }, [mnemonic]); + return (!MNEMONIC_COUNTS.includes(mnemonicValues.length) && !isMnemonicPrivateKey(mnemonicValues)) + || !!error; + }, [mnemonic, error]); const handleSetWord = useLastCallback((value: string, index: number) => { + cleanAuthError(); const pastedMnemonic = parsePastedText(value); if (MNEMONIC_COUNTS.includes(pastedMnemonic.length)) { handleMnemonicSet(pastedMnemonic); diff --git a/src/components/common/FeeDetailsModal.module.scss b/src/components/common/FeeDetailsModal.module.scss index 85e47f1c..aa99bb6e 100644 --- a/src/components/common/FeeDetailsModal.module.scss +++ b/src/components/common/FeeDetailsModal.module.scss @@ -54,7 +54,7 @@ $chartLineInnerRadius: 0.1875rem; } &.excessFee { - flex: 10 1 auto; + flex: 2 1 auto; padding-inline-start: $chartLineInnerPadding; diff --git a/src/components/explore/CategoryHeader.module.scss b/src/components/explore/CategoryHeader.module.scss index 7759947f..5961ea42 100644 --- a/src/components/explore/CategoryHeader.module.scss +++ b/src/components/explore/CategoryHeader.module.scss @@ -46,25 +46,6 @@ bottom: -0.25px !important; } } - - @include respond-above(xs) { - position: relative; - - &::after { - content: ""; - - position: absolute; - bottom: 0; - left: 50%; - transform: translate(-50%, -0.03125rem); - - width: 100vw; - height: 0.0625rem; - - /* stylelint-disable-next-line plugin/whole-pixel */ - box-shadow: 0 0.025rem 0 0 var(--color-separator); - } - } } .backButton { @@ -73,7 +54,7 @@ display: flex; align-items: center; - height: 2.75rem; + height: 3rem; padding: 0.125rem 0.5rem 0; font-size: 0.9375rem; @@ -85,7 +66,7 @@ display: flex; align-items: center; - height: 1.3125rem; + height: var(--header-title-height); padding: 0.0625rem 0.375rem; font-size: 1.0625rem; @@ -103,6 +84,7 @@ font-size: 1.0625rem; font-weight: 700; + line-height: var(--header-title-height); color: var(--color-black); text-overflow: ellipsis; white-space: nowrap; diff --git a/src/components/explore/Explore.module.scss b/src/components/explore/Explore.module.scss index 9072d21e..d1b5c4ec 100644 --- a/src/components/explore/Explore.module.scss +++ b/src/components/explore/Explore.module.scss @@ -294,6 +294,10 @@ $imageGapSize: 0.75rem; } .infoWrapper { + display: flex; + flex-direction: column; + justify-content: center; + line-height: 1.0625rem; } @@ -361,7 +365,7 @@ $imageGapSize: 0.75rem; z-index: 3; top: 0; - padding-top: max(0.5rem, var(--safe-area-top)); + padding-top: calc(0.5rem + var(--safe-area-top)); background-color: var(--color-background-second); @@ -400,7 +404,7 @@ $imageGapSize: 0.75rem; @include adapt-margin-to-scrollbar(1rem); @include respond-below(xs) { - margin-top: 0.5rem; + margin-top: 0.25rem; background-color: var(--color-gray-button-background); } diff --git a/src/components/explore/SiteList.module.scss b/src/components/explore/SiteList.module.scss index 05d6898a..1e1e24fe 100644 --- a/src/components/explore/SiteList.module.scss +++ b/src/components/explore/SiteList.module.scss @@ -1,9 +1,9 @@ @import "../../styles/mixins/index"; .root { - --header-padding-top: 1.5rem; - --header-title-height: 1.1875rem; - --header-padding-bottom: 1.375rem; + --header-padding-top: 1.125rem; + --header-title-height: 1.0625rem; + --header-padding-bottom: 0.8125rem; position: relative; diff --git a/src/components/main/sections/Actions/BottomBar.module.scss b/src/components/main/sections/Actions/BottomBar.module.scss index 8a219af3..2bd8119a 100644 --- a/src/components/main/sections/Actions/BottomBar.module.scss +++ b/src/components/main/sections/Actions/BottomBar.module.scss @@ -8,7 +8,7 @@ grid-template-columns: repeat(3, 1fr); width: 100%; - padding-bottom: max(0.375rem, var(--safe-area-bottom)); + padding-bottom: var(--safe-area-bottom); background-color: var(--color-app-background); /* stylelint-disable-next-line plugin/whole-pixel */ @@ -34,9 +34,11 @@ .button { display: flex; flex-direction: column; + gap: 0.1875rem; align-items: center; + justify-content: center; - height: 3.25rem; + height: 3.125rem; color: var(--color-gray-2); text-align: center; @@ -48,9 +50,11 @@ .icon { font-size: 2rem; + line-height: 2rem; } .label { font-size: 0.625rem; + font-weight: 600; line-height: 1; } diff --git a/src/components/main/sections/Actions/BottomBar.tsx b/src/components/main/sections/Actions/BottomBar.tsx index 8e8bcbc6..2a061eb8 100644 --- a/src/components/main/sections/Actions/BottomBar.tsx +++ b/src/components/main/sections/Actions/BottomBar.tsx @@ -67,13 +67,13 @@ function BottomBar({ areSettingsOpen, areAssetsActive, isExploreOpen }: StatePro closeSiteCategory(undefined, { forceOnHeavyAnimation: true }); } - openExplore(undefined, { forceOnHeavyAnimation: true }); closeSettings(undefined, { forceOnHeavyAnimation: true }); + openExplore(undefined, { forceOnHeavyAnimation: true }); }); const handleSettingsClick = useLastCallback(() => { - openSettings(undefined, { forceOnHeavyAnimation: true }); closeExplore(undefined, { forceOnHeavyAnimation: true }); + openSettings(undefined, { forceOnHeavyAnimation: true }); }); useHistoryBack({ diff --git a/src/components/main/sections/Card/StickyCard.module.scss b/src/components/main/sections/Card/StickyCard.module.scss index 11175a4c..3a7acb62 100644 --- a/src/components/main/sections/Card/StickyCard.module.scss +++ b/src/components/main/sections/Card/StickyCard.module.scss @@ -71,14 +71,8 @@ align-items: center; max-width: 27rem; - height: 3.75rem; + height: var(--sticky-card-height); margin: 0 auto; - - :global(html.with-safe-area-top) & { - box-sizing: content-box; - height: 1.5rem; - padding-bottom: 1.125rem; - } } .account { diff --git a/src/components/main/sections/Content/Content.module.scss b/src/components/main/sections/Content/Content.module.scss index ab48daf5..31867afc 100644 --- a/src/components/main/sections/Content/Content.module.scss +++ b/src/components/main/sections/Content/Content.module.scss @@ -34,9 +34,10 @@ flex-shrink: 0; - height: 2.75rem; + height: 3rem; transition: background-color 150ms; + :global(html.animation-level-0) & { transition: none !important; } @@ -47,7 +48,7 @@ position: absolute; bottom: 0; left: 50%; - transform: translate(-50%, -0.03125rem); + transform: translate(-50%, -0.0625rem); width: 100%; height: 0.0625rem; @@ -56,8 +57,9 @@ box-shadow: 0 0.025rem 0 0 var(--color-separator); @media (-webkit-max-device-pixel-ratio: 1.3) { - /* stylelint-disable-next-line plugin/whole-pixel */ - box-shadow: 0 0.034375rem 0 0 var(--color-separator); + transform: translate(-50%, 0); + + box-shadow: inset 0 0.0625rem 0 0 var(--color-separator); } } @@ -68,7 +70,6 @@ top: var(--sticky-card-height); width: 100%; - height: 3rem; } } @@ -92,7 +93,6 @@ .landscapeContainer & { justify-content: flex-start; - height: 2.75rem; padding: 0 0.75rem; background-color: var(--color-background-first); @@ -101,8 +101,13 @@ } .tab { + /* stylelint-disable-next-line plugin/whole-pixel */ + --tab-platform-height: 0.15625rem; + flex: 1 1 33.3%; + font-size: 1rem; + .landscapeContainer & { flex: 0 0 auto; @@ -111,12 +116,7 @@ } .portraitContainer & { - /* stylelint-disable-next-line plugin/whole-pixel */ - --tab-platform-height: 0.15625rem; - padding: 0.5rem 0.25rem; - - font-size: 1rem; } } diff --git a/src/components/main/sections/Content/NftCollectionHeader.module.scss b/src/components/main/sections/Content/NftCollectionHeader.module.scss index 92958188..bc31e249 100644 --- a/src/components/main/sections/Content/NftCollectionHeader.module.scss +++ b/src/components/main/sections/Content/NftCollectionHeader.module.scss @@ -5,7 +5,9 @@ align-items: center; width: 100%; - height: 2.75rem; + height: 3rem; + + color: var(--color-black); background: var(--color-background-first); border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; @@ -27,10 +29,10 @@ display: flex; align-items: center; - height: 2.75rem; + height: 3rem; padding: 0.125rem 0.5rem 0; - font-size: 0.9375rem; + font-size: 1.0625rem; color: var(--color-accent); } @@ -50,7 +52,7 @@ .title { overflow: hidden; - font-size: 0.9375rem; + font-size: 1rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -67,8 +69,8 @@ top: 0; right: 0; - width: 2.75rem; - height: 2.75rem; + width: 3rem; + height: 3rem; padding-top: 0.3125rem; padding-right: 0.125rem; diff --git a/src/components/main/sections/Content/NftMenu.module.scss b/src/components/main/sections/Content/NftMenu.module.scss index 8080dbef..fb7fda0a 100644 --- a/src/components/main/sections/Content/NftMenu.module.scss +++ b/src/components/main/sections/Content/NftMenu.module.scss @@ -62,6 +62,10 @@ --offset-x-value: 0.25rem; } +.menuBubble { + max-height: 60vh; +} + .item { > :global(.icon) { order: 2; diff --git a/src/components/main/sections/Content/NftMenu.tsx b/src/components/main/sections/Content/NftMenu.tsx index b6a4d4af..a9a6a129 100644 --- a/src/components/main/sections/Content/NftMenu.tsx +++ b/src/components/main/sections/Content/NftMenu.tsx @@ -112,6 +112,7 @@ function NftMenu({ items={menuItems} shouldTranslateOptions className={styles.menu} + bubbleClassName={styles.menuBubble} buttonClassName={styles.item} shouldCleanup onClose={onClose} diff --git a/src/components/settings/Settings.module.scss b/src/components/settings/Settings.module.scss index 5f0ceb8d..f84bb9ba 100644 --- a/src/components/settings/Settings.module.scss +++ b/src/components/settings/Settings.module.scss @@ -1,9 +1,9 @@ @import "../../styles/mixins"; .wrapper { - --header-padding-top: 1.5rem; - --header-title-height: 1.1875rem; - --header-padding-bottom: 1.375rem; + --header-padding-top: 1.125rem; + --header-title-height: 1.0625rem; + --header-padding-bottom: 0.8125rem; height: 100%; @@ -110,6 +110,7 @@ display: flex; align-items: center; + height: var(--header-title-height); padding: 0.0625rem 0.375rem; font-size: 1.0625rem; diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index b259888f..d00887e8 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -735,6 +735,7 @@ function Settings({ case SettingsState.WalletVersion: return ( (currentDexLabel); const renderedDexItems = useCurrentOrPrev(estimates?.length ? estimates : undefined, true); + const renderedCurrentDexLabel = useCurrentOrPrev(currentDexLabel, true); + const renderedBestRateDexLabel = useCurrentOrPrev(bestRateDexLabel, true); const isBestRateSelected = Boolean(bestRateDexLabel && bestRateDexLabel === selectedDexLabel); const confirmLabel = isBestRateSelected ? lang('Use Best Rate') @@ -101,9 +103,9 @@ function SwapDexChooser({ decimals: inputSource === SwapInputSource.In ? tokenOut?.decimals : tokenIn?.decimals, estimates, inputSource, - bestRateDexLabel, + bestRateDexLabel: renderedBestRateDexLabel, }); - }, [bestRateDexLabel, estimates, inputSource, ourFeePercent, tokenIn?.decimals, tokenOut?.decimals]); + }, [renderedBestRateDexLabel, estimates, inputSource, ourFeePercent, tokenIn?.decimals, tokenOut?.decimals]); const renderedAmounts = useMemo | undefined>(() => { if (!renderedDexItems?.length || renderedDexItems.length < 2) return undefined; @@ -131,11 +133,11 @@ function SwapDexChooser({ const buttonContent = ( <> - {bestRateDexLabel === currentDexLabel && renderedDexItems.length > 1 && ( + {renderedBestRateDexLabel === renderedCurrentDexLabel && renderedDexItems.length > 1 && ( {lang('Best Rate')} )} {lang('via %dex_name%', { - dex_name: {SWAP_DEX_LABELS[currentDexLabel!]}, + dex_name: {SWAP_DEX_LABELS[renderedCurrentDexLabel!]}, })} {renderedDexItems.length > 1 && ( @@ -163,7 +165,7 @@ function SwapDexChooser({ role="button" className={buildClassName( styles.dexItem, - bestRateDexLabel === item.dexLabel && styles.bestRate, + renderedBestRateDexLabel === item.dexLabel && styles.bestRate, selectedDexLabel === item.dexLabel && styles.current, )} onClick={() => { setSelectedDexLabel(item.dexLabel); }} @@ -180,7 +182,7 @@ function SwapDexChooser({
{rate ? <>{rate.firstCurrencySymbol} ≈ {rate.price} {rate.secondCurrencySymbol} : undefined}
- {item.dexLabel === bestRateDexLabel && ( + {item.dexLabel === renderedBestRateDexLabel && ( {lang('Best')} )} diff --git a/src/components/swap/SwapInitial.tsx b/src/components/swap/SwapInitial.tsx index d5892758..b6d13e67 100644 --- a/src/components/swap/SwapInitial.tsx +++ b/src/components/swap/SwapInitial.tsx @@ -15,14 +15,18 @@ import { SwapInputSource, SwapState, SwapType } from '../../global/types'; import { ANIMATED_STICKER_TINY_SIZE_PX, ANIMATION_LEVEL_MAX, - CHAIN_CONFIG, CHANGELLY_AML_KYC, CHANGELLY_PRIVACY_POLICY, CHANGELLY_TERMS_OF_USE, DEFAULT_SWAP_SECOND_TOKEN_SLUG, TONCOIN, } from '../../config'; -import { selectCurrentAccount, selectIsMultichainAccount, selectSwapTokens } from '../../global/selectors'; +import { + selectCurrentAccount, + selectIsMultichainAccount, + selectSwapTokens, + selectSwapType, +} from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { vibrate } from '../../util/capacitor'; import { findChainConfig } from '../../util/chain'; @@ -38,7 +42,6 @@ import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import usePrevious from '../../hooks/usePrevious'; import useSyncEffect from '../../hooks/useSyncEffect'; -import useThrottledCallback from '../../hooks/useThrottledCallback'; import FeeDetailsModal from '../common/FeeDetailsModal'; import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; @@ -64,6 +67,7 @@ interface StateProps { accountId?: string; tokens?: UserSwapToken[]; isMultichainAccount?: boolean; + swapType: SwapType; } const ESTIMATE_REQUEST_INTERVAL = 1_000; @@ -82,7 +86,6 @@ function SwapInitial({ realNetworkFee, priceImpact = 0, inputSource, - swapType, limits, isLoading, pairs, @@ -97,18 +100,16 @@ function SwapInitial({ isActive, isStatic, isMultichainAccount, + swapType, }: OwnProps & StateProps) { const { setDefaultSwapParams, setSwapAmountIn, - setSwapIsMaxAmount, setSwapAmountOut, switchSwapTokens, estimateSwap, - estimateSwapCex, setSwapScreen, loadSwapPairs, - setSwapType, setSwapCexAddress, authorizeDiesel, showNotification, @@ -119,8 +120,6 @@ function SwapInitial({ const inputInRef = useRef(null); // eslint-disable-next-line no-null/no-null const inputOutRef = useRef(null); - // eslint-disable-next-line no-null/no-null - const estimateIntervalId = useRef(null); const currentTokenInSlug = tokenInSlug ?? TONCOIN.slug; const currentTokenOutSlug = tokenOutSlug ?? DEFAULT_SWAP_SECOND_TOKEN_SLUG; @@ -220,31 +219,18 @@ function SwapInitial({ lang, ); - const handleEstimateSwap = useLastCallback((shouldBlock: boolean) => { - if (!isActive || isBackgroundModeActive()) return; - - if (isCrosschain) { - estimateSwapCex({ shouldBlock }); - return; - } + const handleEstimateSwap = useLastCallback(() => { + if ((!isActive || isBackgroundModeActive()) && !shouldEstimate) return; - estimateSwap({ shouldBlock }); + estimateSwap(); }); - const throttledEstimateSwap = useThrottledCallback( - handleEstimateSwap, [handleEstimateSwap], ESTIMATE_REQUEST_INTERVAL, true, - ); const debounceSetAmountIn = useDebouncedCallback( setSwapAmountIn, [setSwapAmountIn], SET_AMOUNT_DEBOUNCE_TIME, true, ); const debounceSetAmountOut = useDebouncedCallback( setSwapAmountOut, [setSwapAmountOut], SET_AMOUNT_DEBOUNCE_TIME, true, ); - const createEstimateTimer = useLastCallback(() => { - estimateIntervalId.current = window.setInterval(() => { - throttledEstimateSwap(false); - }, ESTIMATE_REQUEST_INTERVAL); - }); const [currentSubModal, openSettingsModal, openFeeModal, closeSubModal] = useSubModals(explainedFee); @@ -255,17 +241,13 @@ function SwapInitial({ }, [tokenInSlug, tokenOutSlug]); useEffect(() => { - const clearEstimateTimer = () => estimateIntervalId.current && window.clearInterval(estimateIntervalId.current); - if (shouldEstimate) { - clearEstimateTimer(); - throttledEstimateSwap(true); + handleEstimateSwap(); } - createEstimateTimer(); - - return clearEstimateTimer; - }, [shouldEstimate, createEstimateTimer, throttledEstimateSwap]); + const intervalId = setInterval(handleEstimateSwap, ESTIMATE_REQUEST_INTERVAL); + return () => clearInterval(intervalId); + }, [shouldEstimate]); useEffect(() => { const shouldForceUpdate = accountId !== accountIdPrev; @@ -278,46 +260,15 @@ function SwapInitial({ } }, [accountId, accountIdPrev, currentTokenInSlug, currentTokenOutSlug]); - useEffect(() => { - if (!tokenIn || !tokenOut) { - return; - } - - const isInTonToken = tokenIn?.chain === 'ton'; - const isOutTonToken = tokenOut?.chain === 'ton'; - - if (isInTonToken && isOutTonToken) { - setSwapType({ type: SwapType.OnChain }); - return; - } - - if (isMultichainAccount) { - if (tokenIn.chain in CHAIN_CONFIG) { - setSwapType({ type: SwapType.CrosschainFromWallet }); - } else { - setSwapType({ type: SwapType.CrosschainToWallet }); - } - return; - } - - if (isInTonToken && !isOutTonToken) { - setSwapType({ type: SwapType.CrosschainFromWallet }); - } else if (!isInTonToken && isOutTonToken) { - setSwapType({ type: SwapType.CrosschainToWallet }); - } - }, [tokenIn, tokenOut, isMultichainAccount]); - const handleAmountInChange = useLastCallback( (amount: string | undefined) => { - setSwapIsMaxAmount({ isMaxAmount: false }); debounceSetAmountIn({ amount: amount || undefined }); }, ); const handleAmountOutChange = useLastCallback( (amount: string | undefined) => { - setSwapIsMaxAmount({ isMaxAmount: false }); - debounceSetAmountOut({ amount }); + debounceSetAmountOut({ amount: amount || undefined }); }, ); @@ -331,8 +282,7 @@ function SwapInitial({ void vibrate(); const amount = toDecimal(maxAmount, tokenIn!.decimals); - setSwapIsMaxAmount({ isMaxAmount: true }); - setSwapAmountIn({ amount }); + setSwapAmountIn({ amount, isMaxAmount: true }); }; const handleSubmit = useLastCallback((e) => { @@ -543,7 +493,7 @@ function SwapInitial({ - {!isCrosschain && } +
{renderFee()} @@ -599,6 +549,7 @@ export default memo( tokens: selectSwapTokens(global), addressByChain: account?.addressByChain, isMultichainAccount: selectIsMultichainAccount(global, global.currentAccountId!), + swapType: selectSwapType(global), }; }, (global, _, stickToFirst) => stickToFirst(global.currentAccountId), diff --git a/src/components/swap/SwapModal.tsx b/src/components/swap/SwapModal.tsx index e82cafaf..a2ccbcb3 100644 --- a/src/components/swap/SwapModal.tsx +++ b/src/components/swap/SwapModal.tsx @@ -12,6 +12,7 @@ import { selectCurrentAccount, selectCurrentAccountState, selectSwapTokens, + selectSwapType, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { formatCurrencyExtended } from '../../util/formatNumber'; @@ -38,6 +39,7 @@ import styles from './Swap.module.scss'; interface StateProps { currentSwap: GlobalState['currentSwap']; + swapType: SwapType; swapTokens?: UserSwapToken[]; activityById?: Record; addressByChain?: Account['addressByChain']; @@ -55,13 +57,13 @@ function SwapModal({ isLoading, error, activityId, - swapType, toAddress, payinAddress, payoutAddress, payinExtraId, shouldResetOnClose, }, + swapType, swapTokens, activityById, addressByChain, @@ -301,6 +303,7 @@ export default memo(withGlobal((global): StateProps => { return { currentSwap: global.currentSwap, + swapType: selectSwapType(global), swapTokens: selectSwapTokens(global), activityById, addressByChain: account?.addressByChain, diff --git a/src/components/swap/SwapSettingsModal.tsx b/src/components/swap/SwapSettingsModal.tsx index 8ee4aa51..bce06fb6 100644 --- a/src/components/swap/SwapSettingsModal.tsx +++ b/src/components/swap/SwapSettingsModal.tsx @@ -11,6 +11,7 @@ import { selectCurrentAccountTokenBalance, selectCurrentSwapTokenIn, selectCurrentSwapTokenOut, + selectSwapType, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { findChainConfig } from '../../util/chain'; @@ -46,7 +47,7 @@ interface StateProps { tokenOut?: ApiSwapAsset; networkFee?: string; realNetworkFee?: string; - swapType?: SwapType; + swapType: SwapType; slippage: number; priceImpact?: number; amountOutMin?: string; @@ -353,7 +354,6 @@ const SwapSettings = memo( amountOut, networkFee, realNetworkFee, - swapType, slippage, priceImpact, amountOutMin, @@ -371,7 +371,7 @@ const SwapSettings = memo( amountOut, networkFee, realNetworkFee, - swapType, + swapType: selectSwapType(global), tokenIn: selectCurrentSwapTokenIn(global), tokenOut: selectCurrentSwapTokenOut(global), slippage, diff --git a/src/components/transfer/TransferInitial.tsx b/src/components/transfer/TransferInitial.tsx index 031a5966..b548034e 100644 --- a/src/components/transfer/TransferInitial.tsx +++ b/src/components/transfer/TransferInitial.tsx @@ -13,7 +13,7 @@ import type { DropdownItem } from '../ui/Dropdown'; import { TransferState } from '../../global/types'; import { - CHAIN_CONFIG, IS_FIREFOX_EXTENSION, PRICELESS_TOKEN_HASHES, STAKED_TOKEN_SLUGS, TONCOIN, + IS_FIREFOX_EXTENSION, PRICELESS_TOKEN_HASHES, STAKED_TOKEN_SLUGS, TONCOIN, } from '../../config'; import { Big } from '../../lib/big.js'; import renderText from '../../global/helpers/renderText'; @@ -104,7 +104,6 @@ const COMMENT_DROPDOWN_ITEMS = [ ]; const ACTIVE_STATES = new Set([TransferState.Initial, TransferState.None]); const AUTHORIZE_DIESEL_INTERVAL_MS = SECOND; -const TRON_ADDRESS_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{1,33}$/; const INPUT_CLEAR_BUTTON_ID = 'input-clear-button'; @@ -183,6 +182,7 @@ function TransferInitial({ price, symbol, chain, + codeHash, } = transferToken || {}; // Note: As of 27-11-2023, Firefox does not support readText() @@ -331,15 +331,17 @@ function TransferInitial({ tokenSlug, ]); - const handleTokenChange = useLastCallback((slug: string) => { - changeTransferToken({ tokenSlug: slug }); - const token = tokens?.find((t) => t.slug === slug); - if (STAKED_TOKEN_SLUGS.has(slug) || PRICELESS_TOKEN_HASHES.has(token?.codeHash!)) { + useEffect(() => { + if (STAKED_TOKEN_SLUGS.has(tokenSlug) || PRICELESS_TOKEN_HASHES.has(codeHash ?? '')) { showDialog({ title: lang('Warning!'), message: lang('$service_token_transfer_warning'), }); } + }, [tokenSlug, codeHash, lang]); + + const handleTokenChange = useLastCallback((slug: string) => { + changeTransferToken({ tokenSlug: slug }); }); const handleAddressBookClose = useLastCallback(() => { @@ -396,7 +398,6 @@ function TransferInitial({ setTransferToAddress({ toAddress: toAddress.toLowerCase().trim() }); } else if (toAddress !== toAddress.trim()) { setTransferToAddress({ toAddress: toAddress.trim() }); - parseAddressAndUpdateToken(toAddress.trim()); } requestAnimationFrame(() => { @@ -406,7 +407,6 @@ function TransferInitial({ const handleAddressInput = useLastCallback((newToAddress: string) => { setTransferToAddress({ toAddress: newToAddress }); - parseAddressAndUpdateToken(newToAddress); }); const handleAddressClearClick = useLastCallback(() => { @@ -429,16 +429,6 @@ function TransferInitial({ } }); - function parseAddressAndUpdateToken(address: string) { - if (!address || amount || !isMultichainAccount || !tokens) return; - const chainFromAddress = getChainFromAddress(address); - if (chainFromAddress === chain) return; - - const newTokenSlug = findTokenSlugWithMaxBalance(tokens, chainFromAddress) - || CHAIN_CONFIG[chainFromAddress].nativeToken.slug; - handleTokenChange(newTokenSlug); - } - const handlePasteClick = useLastCallback(async () => { try { const { type, text } = await readClipboardContent(); @@ -446,7 +436,6 @@ function TransferInitial({ if (type === 'text/plain') { isDisabledDebounce.current = true; setTransferToAddress({ toAddress: text.trim() }); - parseAddressAndUpdateToken(text.trim()); } } catch (err: any) { showNotification({ message: lang('Error reading clipboard') }); @@ -458,7 +447,6 @@ function TransferInitial({ (address: string) => { isDisabledDebounce.current = true; setTransferToAddress({ toAddress: address }); - parseAddressAndUpdateToken(address); closeAddressBook(); }, ); @@ -1085,31 +1073,6 @@ function renderAddressItem({ ); } -function findTokenSlugWithMaxBalance(tokens: UserToken[], chain: ApiChain) { - const resultToken = tokens - .filter((token) => token.chain === chain) - .reduce((maxToken, currentToken) => { - const currentBalance = currentToken.priceUsd * Number(currentToken.amount); - const maxBalance = maxToken ? maxToken.priceUsd * Number(maxToken.amount) : 0; - - return currentBalance > maxBalance ? currentToken : maxToken; - }); - - return resultToken?.slug; -} - -function getIsTronAddress(address: string) { - return TRON_ADDRESS_REGEX.test(address); -} - -function getChainFromAddress(address: string): ApiChain { - if (getIsTronAddress(address)) { - return 'tron'; - } - - return 'ton'; -} - function useFeeModal(explainedFee: ExplainedTransferFee) { const isAvailable = explainedFee.realFee?.precision !== 'exact'; const [isOpen, open, close] = useFlag(false); diff --git a/src/components/ui/Dropdown.module.scss b/src/components/ui/Dropdown.module.scss index f6d2d550..a619ced8 100644 --- a/src/components/ui/Dropdown.module.scss +++ b/src/components/ui/Dropdown.module.scss @@ -4,7 +4,7 @@ align-items: center; - color: var(--color-input-button-text); + color: var(--color-gray-button-text); border: none; border-radius: var(--border-radius-small); diff --git a/src/config.ts b/src/config.ts index bad028c0..f5a6f15e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import type { ApiTonWalletVersion } from './api/chains/ton/types'; import type { ApiBaseCurrency, ApiLiquidStakingState, + ApiNominatorsStakingState, ApiSwapAsset, ApiSwapDexLabel, } from './api/types'; @@ -135,7 +136,7 @@ export const PROXY_HOSTS = process.env.PROXY_HOSTS; export const TINY_TRANSFER_MAX_COST = 0.01; export const IMAGE_CACHE_NAME = 'mtw-image'; -export const LANG_CACHE_NAME = 'mtw-lang-164'; +export const LANG_CACHE_NAME = 'mtw-lang-167'; export const LANG_LIST: LangItem[] = [{ langCode: 'en', @@ -251,12 +252,14 @@ export const CHAIN_CONFIG = { isMemoSupported: true, isDnsSupported: true, addressRegex: /^([-\w_]{48}|0:[\da-h]{64})$/i, + addressPrefixRegex: /^([-\w_]{1,48}|0:[\da-h]{0,64})$/i, nativeToken: TONCOIN, }, tron: { isMemoSupported: false, isDnsSupported: false, addressRegex: /^T[1-9A-HJ-NP-Za-km-z]{33}$/, + addressPrefixRegex: /^T[1-9A-HJ-NP-Za-km-z]{0,33}$/, nativeToken: TRX, mainnet: { apiUrl: TRON_MAINNET_API_URL, @@ -552,6 +555,19 @@ export const DEFAULT_STAKING_STATE: ApiLiquidStakingState = { instantAvailable: 0n, }; +export const DEFAULT_NOMINATORS_STAKING_STATE: ApiNominatorsStakingState = { + type: 'nominators', + id: 'nominators', + tokenSlug: TONCOIN.slug, + annualYield: 3.9, + yieldType: 'APY', + balance: 0n, + pool: 'Ef8dgIOIRyCLU0NEvF8TD6Me3wrbrkS1z3Gpjk3ppd8m8-s_', + start: 0, + end: 0, + pendingDepositAmount: 0n, +}; + export const SWAP_API_VERSION = 2; export const JVAULT_URL = 'https://jvault.xyz'; diff --git a/src/global/actions/api/swap.ts b/src/global/actions/api/swap.ts index b4197088..b4a03dea 100644 --- a/src/global/actions/api/swap.ts +++ b/src/global/actions/api/swap.ts @@ -34,9 +34,7 @@ import { import { Big } from '../../../lib/big.js'; import { vibrateOnError, vibrateOnSuccess } from '../../../util/capacitor'; import { findChainConfig, getChainConfig } from '../../../util/chain'; -import { - fromDecimal, getIsPositiveDecimal, roundDecimal, toDecimal, -} from '../../../util/decimals'; +import { fromDecimal, roundDecimal, toDecimal } from '../../../util/decimals'; import { canAffordSwapEstimateVariant, shouldSwapBeGasless } from '../../../util/fee/swapFee'; import generateUniqueId from '../../../util/generateUniqueId'; import { buildCollectionByKey, pick } from '../../../util/iteratees'; @@ -48,6 +46,12 @@ import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET } from '../../../ import { callApi } from '../../../api'; import { addActionHandler, getGlobal, setGlobal } from '../..'; import { resolveSwapAssetId } from '../../helpers'; +import { + getSwapEstimateResetParams, + isSwapEstimateInputEqual, + isSwapFormFilled, + shouldAvoidSwapEstimation, +} from '../../helpers/swap'; import { clearCurrentSwap, clearIsPinAccepted, @@ -64,6 +68,7 @@ import { selectCurrentSwapTokenOut, selectCurrentToncoinBalance, selectIsMultichainAccount, + selectSwapType, } from '../../selectors'; import { getIsPortrait } from '../../../hooks/useDeviceScreen'; @@ -73,8 +78,6 @@ let pairsCache: Record const CACHE_DURATION = 15 * 60 * 1000; // 15 minutes const WAIT_FOR_CHANGELLY = 5 * 1000; const CLOSING_BOTTOM_SHEET_DURATION = 100; // Like in `useDelegatingBottomSheet` -let isEstimateSwapBeingExecuted = false; -let isEstimateCexSwapBeingExecuted = false; const SERVER_ERRORS_MAP = { 'Insufficient liquidity': SwapErrorType.NotEnoughLiquidity, @@ -84,17 +87,6 @@ const SERVER_ERRORS_MAP = { 'Too small amount': SwapErrorType.TooSmallAmount, }; -const feeResetParams = { - networkFee: undefined, - realNetworkFee: undefined, - swapFee: undefined, - swapFeePercent: undefined, - ourFee: undefined, - ourFeePercent: undefined, - dieselStatus: undefined, - dieselFee: undefined, -}; - function buildSwapBuildRequest(global: GlobalState): ApiSwapBuildRequest { const { currentDexLabel, @@ -118,6 +110,7 @@ function buildSwapBuildRequest(global: GlobalState): ApiSwapBuildRequest { const account = selectAccount(global, global.currentAccountId!); const nativeTokenIn = findChainConfig(getChainBySlug(tokenIn.slug))?.nativeToken; const nativeTokenInBalance = nativeTokenIn ? selectCurrentAccountTokenBalance(global, nativeTokenIn.slug) : undefined; + const swapType = selectSwapType(global); return { from, @@ -127,7 +120,7 @@ function buildSwapBuildRequest(global: GlobalState): ApiSwapBuildRequest { toMinAmount: amountOutMin!, slippage, fromAddress: account?.addressByChain[tokenIn.chain as ApiChain] || account?.addressByChain.ton!, - shouldTryDiesel: shouldSwapBeGasless({ ...global.currentSwap, nativeTokenInBalance }), + shouldTryDiesel: shouldSwapBeGasless({ ...global.currentSwap, swapType, nativeTokenInBalance }), dexLabel: currentDexLabel!, networkFee: realNetworkFee ?? networkFee!, swapFee: swapFee!, @@ -169,7 +162,7 @@ function processNativeMaxSwap(global: GlobalState) { if ( global.currentSwap.amountIn - && global.currentSwap.swapType === SwapType.OnChain + && selectSwapType(global) === SwapType.OnChain && global.currentSwap.inputSource === SwapInputSource.In && global.currentSwap.isMaxAmount ) { @@ -197,37 +190,16 @@ addActionHandler('startSwap', async (global, actions, payload) => { return; } - const { - state, tokenInSlug, tokenOutSlug, amountIn, toAddress, - } = payload ?? {}; + const { state } = payload ?? {}; const isPortrait = getIsPortrait(); - if (tokenInSlug || tokenOutSlug || amountIn || toAddress) { - const tokenIn = global.swapTokenInfo?.bySlug[tokenInSlug!]; - const tokenOut = global.swapTokenInfo?.bySlug[tokenOutSlug!]; - const account = selectAccount(global, global.currentAccountId!); - - const isCrosschain = tokenIn?.chain !== 'ton' || tokenOut?.chain !== 'ton'; - const isToSupportedChain = Boolean(account?.addressByChain[tokenOut?.chain as ApiChain]); - - const swapType = isCrosschain - ? (isToSupportedChain ? SwapType.CrosschainToWallet : SwapType.CrosschainFromWallet) - : SwapType.OnChain; - - global = updateCurrentSwap(global, { - ...payload, - isEstimating: true, - shouldEstimate: true, - inputSource: SwapInputSource.In, - swapType, - }); - } - const requiredState = state || (isPortrait ? SwapState.Initial : SwapState.None); global = updateCurrentSwap(global, { + ...payload, state: requiredState, swapId: generateUniqueId(), + inputSource: SwapInputSource.In, }); setGlobal(global); @@ -251,13 +223,9 @@ addActionHandler('setDefaultSwapParams', (global, actions, payload) => { } global = updateCurrentSwap(global, { - ...feeResetParams, tokenInSlug: requiredTokenInSlug, tokenOutSlug: requiredTokenOutSlug, - priceImpact: 0, - amountOutMin: '0', inputSource: SwapInputSource.In, - isDexLabelChanged: undefined, ...(withResetAmount ? { amountIn: undefined, amountOut: undefined } : undefined), }); setGlobal(global); @@ -265,22 +233,15 @@ addActionHandler('setDefaultSwapParams', (global, actions, payload) => { addActionHandler('cancelSwap', (global, actions, { shouldReset } = {}) => { if (shouldReset) { - const { - tokenInSlug, tokenOutSlug, pairs, swapType, - } = global.currentSwap; + const { tokenInSlug, tokenOutSlug, pairs } = global.currentSwap; global = clearCurrentSwap(global); global = updateCurrentSwap(global, { - ...feeResetParams, tokenInSlug, tokenOutSlug, - priceImpact: 0, amountIn: undefined, - amountOutMin: undefined, amountOut: undefined, inputSource: SwapInputSource.In, - isDexLabelChanged: undefined, - swapType, pairs, }); @@ -521,15 +482,9 @@ addActionHandler('submitSwapCex', async (global, actions, { password }) => { addActionHandler('switchSwapTokens', (global) => { const { - tokenInSlug, tokenOutSlug, amountIn, amountOut, swapType, + tokenInSlug, tokenOutSlug, amountIn, amountOut, } = global.currentSwap; - const newSwapType = swapType === SwapType.OnChain - ? SwapType.OnChain - : swapType === SwapType.CrosschainFromWallet - ? SwapType.CrosschainToWallet - : SwapType.CrosschainFromWallet; - global = updateCurrentSwap(global, { isMaxAmount: false, amountIn: amountOut, @@ -537,9 +492,6 @@ addActionHandler('switchSwapTokens', (global) => { tokenInSlug: tokenOutSlug, tokenOutSlug: tokenInSlug, inputSource: SwapInputSource.In, - swapType: newSwapType, - isEstimating: true, - shouldEstimate: true, }); setGlobal(global); }); @@ -551,7 +503,6 @@ addActionHandler('setSwapTokenIn', (global, actions, { tokenSlug: newTokenInSlug tokenInSlug, tokenOutSlug, } = global.currentSwap; - const isFilled = Boolean(amountIn || amountOut); const newTokenIn = global.swapTokenInfo!.bySlug[newTokenInSlug]; const adjustedAmountIn = amountIn ? roundDecimal(amountIn, newTokenIn.decimals) : amountIn; @@ -565,10 +516,6 @@ addActionHandler('setSwapTokenIn', (global, actions, { tokenSlug: newTokenInSlug amountOut: adjustedAmountOut === '0' ? undefined : adjustedAmountOut, tokenInSlug: newTokenInSlug, tokenOutSlug: newTokenOutSlug, - isEstimating: isFilled, - shouldEstimate: true, - isMaxAmount: false, - isDexLabelChanged: undefined, }); setGlobal(global); }); @@ -580,7 +527,6 @@ addActionHandler('setSwapTokenOut', (global, actions, { tokenSlug: newTokenOutSl tokenInSlug, tokenOutSlug, } = global.currentSwap; - const isFilled = Boolean(amountIn || amountOut); const newTokenOut = global.swapTokenInfo!.bySlug[newTokenOutSlug!]; const adjustedAmountOut = amountOut ? roundDecimal(amountOut, newTokenOut.decimals) : amountOut; @@ -594,380 +540,209 @@ addActionHandler('setSwapTokenOut', (global, actions, { tokenSlug: newTokenOutSl amountIn: adjustedAmountIn === '0' ? undefined : adjustedAmountIn, tokenOutSlug: newTokenOutSlug, tokenInSlug: newTokenInSlug, - isEstimating: isFilled, - shouldEstimate: true, - isDexLabelChanged: undefined, }); setGlobal(global); }); -addActionHandler('setSwapAmountIn', (global, actions, { amount }) => { - const isEstimating = Boolean(amount && getIsPositiveDecimal(amount)); - +addActionHandler('setSwapAmountIn', (global, actions, { amount, isMaxAmount = false }) => { global = updateCurrentSwap(global, { amountIn: amount, - inputSource: SwapInputSource.In, - isEstimating, - shouldEstimate: true, - isDexLabelChanged: undefined, - }); - setGlobal(global); -}); - -addActionHandler('setSwapIsMaxAmount', (global, actions, { isMaxAmount }) => { - global = updateCurrentSwap(global, { isMaxAmount, + inputSource: SwapInputSource.In, }); setGlobal(global); }); addActionHandler('setSwapAmountOut', (global, actions, { amount }) => { - const isEstimating = Boolean(amount && getIsPositiveDecimal(amount)); - global = updateCurrentSwap(global, { amountOut: amount, + isMaxAmount: false, inputSource: SwapInputSource.Out, - isEstimating, - shouldEstimate: true, - isDexLabelChanged: undefined, }); setGlobal(global); }); addActionHandler('setSlippage', (global, actions, { slippage }) => { - global = updateCurrentSwap(global, { - slippage, - isEstimating: true, - shouldEstimate: true, - }); - setGlobal(global); + return updateCurrentSwap(global, { slippage }); }); -addActionHandler('estimateSwap', async (global, actions, payload) => { - if (isEstimateSwapBeingExecuted || shouldAvoidEstimation(global)) return; - - try { - isEstimateSwapBeingExecuted = true; - - const { shouldBlock } = payload; - - const resetParams = { - ...feeResetParams, - amountOutMin: '0', - priceImpact: 0, - errorType: undefined, - shouldEstimate: false, - isEstimating: false, - }; - - // Check for empty string - if ((global.currentSwap.amountIn === undefined && global.currentSwap.inputSource === SwapInputSource.In) - || (global.currentSwap.amountOut === undefined && global.currentSwap.inputSource === SwapInputSource.Out)) { - global = updateCurrentSwap(global, { - amountIn: undefined, - amountOut: undefined, - ...resetParams, - }); - setGlobal(global); - return; +addActionHandler('estimateSwap', async () => { + await estimateSwapConcurrently((global, shouldStop) => { + if (!isSwapFormFilled(global)) { + return getSwapEstimateResetParams(global); } const pairsBySlug = global.currentSwap.pairs?.bySlug ?? {}; - const canSwap = global.currentSwap.tokenOutSlug! in pairsBySlug; - - // Check for invalid pair - if (!canSwap) { - const amount = global.currentSwap.inputSource === SwapInputSource.In - ? { amountOut: undefined } - : { amountIn: undefined }; - - global = updateCurrentSwap(global, { - ...amount, - ...resetParams, + const isPairValid = global.currentSwap.tokenOutSlug! in pairsBySlug; + if (!isPairValid) { + return { + ...getSwapEstimateResetParams(global), errorType: SwapErrorType.InvalidPair, - }); - setGlobal(global); - return; + }; } - global = updateCurrentSwap(global, { - shouldEstimate: false, - isEstimating: shouldBlock, - }); - setGlobal(global); + if (selectSwapType(global) === SwapType.OnChain) { + return estimateDexSwap(global); + } else { + return estimateCexSwap(global, shouldStop); + } + }); +}); - const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; - const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; - const nativeTokenIn = getChainConfig(getChainBySlug(tokenIn.slug)).nativeToken; - - const from = tokenIn.slug === TONCOIN.slug ? tokenIn.symbol : tokenIn.tokenAddress!; - const to = tokenOut.slug === TONCOIN.slug ? tokenOut.symbol : tokenOut.tokenAddress!; - const { fromAmount, isFromAmountMax } = processNativeMaxSwap(global); - const toAmount = global.currentSwap.amountOut ?? '0'; - const fromAddress = selectCurrentAccount(global)!.addressByChain.ton; - - const estimateAmount = global.currentSwap.inputSource === SwapInputSource.In ? { fromAmount } : { toAmount }; - - const toncoinBalance = selectCurrentToncoinBalance(global); - const shouldTryDiesel = toncoinBalance < fromDecimal(global.currentSwap.networkFee ?? '0', nativeTokenIn.decimals); - - const estimate = await callApi('swapEstimate', global.currentAccountId!, { - ...estimateAmount, - from, - to, - slippage: global.currentSwap.slippage, - fromAddress, - shouldTryDiesel, - isFromAmountMax, - toncoinBalance: toDecimal(toncoinBalance ?? 0n, TONCOIN.decimals), - }); +async function estimateDexSwap(global: GlobalState): Promise { + const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; + const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; + const nativeTokenIn = getChainConfig(getChainBySlug(tokenIn.slug)).nativeToken; - global = getGlobal(); - if (shouldAvoidEstimation(global)) return; + const from = tokenIn.slug === TONCOIN.slug ? tokenIn.symbol : tokenIn.tokenAddress!; + const to = tokenOut.slug === TONCOIN.slug ? tokenOut.symbol : tokenOut.tokenAddress!; + const { fromAmount, isFromAmountMax } = processNativeMaxSwap(global); + const toAmount = global.currentSwap.amountOut ?? '0'; + const fromAddress = selectCurrentAccount(global)!.addressByChain.ton; - if (!estimate || 'error' in estimate) { - const errorType = SERVER_ERRORS_MAP[estimate?.error as keyof typeof SERVER_ERRORS_MAP] - ?? SwapErrorType.UnexpectedError; + const estimateAmount = global.currentSwap.inputSource === SwapInputSource.In ? { fromAmount } : { toAmount }; - global = updateCurrentSwap(global, { - ...resetParams, - errorType, - }); - setGlobal(global); - return; - } + const toncoinBalance = selectCurrentToncoinBalance(global); + const shouldTryDiesel = toncoinBalance < fromDecimal(global.currentSwap.networkFee ?? '0', nativeTokenIn.decimals); - // Check for outdated response - if ( - !isFromAmountMax - && ( - ( - global.currentSwap.inputSource === SwapInputSource.In - && global.currentSwap.amountIn !== estimate.fromAmount - ) || ( - global.currentSwap.inputSource === SwapInputSource.Out - && global.currentSwap.amountOut !== estimate.toAmount - ) - )) { - global = updateCurrentSwap(global, { - ...resetParams, - }); - setGlobal(global); - return; - } + const estimate = await callApi('swapEstimate', global.currentAccountId!, { + ...estimateAmount, + from, + to, + slippage: global.currentSwap.slippage, + fromAddress, + shouldTryDiesel, + isFromAmountMax, + toncoinBalance: toDecimal(toncoinBalance ?? 0n, TONCOIN.decimals), + }); - const errorType = estimate.toAmount === '0' && shouldTryDiesel - ? SwapErrorType.NotEnoughForFee - : undefined; + global = getGlobal(); - const estimates = buildSwapEstimates(estimate); - const currentEstimate = chooseSwapEstimate(global, estimates, estimate.dexLabel); + if (!estimate || 'error' in estimate) { + const errorType = SERVER_ERRORS_MAP[estimate?.error as keyof typeof SERVER_ERRORS_MAP] + ?? SwapErrorType.UnexpectedError; - global = updateCurrentSwap(global, { - ...(global.currentSwap.inputSource === SwapInputSource.In - ? { amountOut: currentEstimate.toAmount } - : { amountIn: currentEstimate.fromAmount } - ), - ...(isFromAmountMax ? { amountIn: currentEstimate.fromAmount } : undefined), - bestRateDexLabel: estimate.dexLabel, - amountOutMin: currentEstimate.toMinAmount, - priceImpact: currentEstimate.impact, - isEstimating: false, + return { + ...getSwapEstimateResetParams(global), errorType, - dieselStatus: estimate.dieselStatus, - estimates, - currentDexLabel: currentEstimate.dexLabel, - // Fees - networkFee: currentEstimate.networkFee, - realNetworkFee: currentEstimate.realNetworkFee, - swapFee: currentEstimate.swapFee, - swapFeePercent: currentEstimate.swapFeePercent, - ourFee: currentEstimate.ourFee, - ourFeePercent: estimate.ourFeePercent, - dieselFee: currentEstimate.dieselFee, - }); - setGlobal(global); - } finally { - isEstimateSwapBeingExecuted = false; - } -}); - -addActionHandler('estimateSwapCex', async (global, actions, { shouldBlock }) => { - if (isEstimateCexSwapBeingExecuted || shouldAvoidEstimation(global)) return; - - try { - isEstimateCexSwapBeingExecuted = true; - - const amount = global.currentSwap.inputSource === SwapInputSource.In - ? { amountOut: undefined } - : { amountIn: undefined }; - - const resetParams = { - ...feeResetParams, - ...amount, - amountOutMin: '0', - priceImpact: 0, - errorType: undefined, - shouldEstimate: false, - isEstimating: false, }; + } - // Check for empty string - const { amountIn, amountOut, inputSource } = global.currentSwap; - if (((amountIn === undefined || amountIn === '0') && inputSource === SwapInputSource.In) - || ((amountOut === undefined || amountOut === '0') && inputSource === SwapInputSource.Out)) { - global = updateCurrentSwap(global, { - amountIn: undefined, - amountOut: undefined, - ...resetParams, - }); - setGlobal(global); - return; - } + const errorType = estimate.toAmount === '0' && shouldTryDiesel + ? SwapErrorType.NotEnoughForFee + : undefined; - const pairsBySlug = global.currentSwap.pairs?.bySlug ?? {}; - const canSwap = global.currentSwap.tokenOutSlug! in pairsBySlug; + const estimates = buildSwapEstimates(estimate); + const currentEstimate = chooseSwapEstimate(global, estimates, estimate.dexLabel); - // Check for invalid pair - if (!canSwap) { - global = updateCurrentSwap(global, { - ...resetParams, - errorType: SwapErrorType.InvalidPair, - }); - setGlobal(global); - return; - } + return { + ...getSwapEstimateResetParams(global), + ...(global.currentSwap.inputSource === SwapInputSource.In + ? { amountOut: currentEstimate.toAmount } + : { amountIn: currentEstimate.fromAmount } + ), + ...(isFromAmountMax ? { amountIn: currentEstimate.fromAmount } : undefined), + bestRateDexLabel: estimate.dexLabel, + amountOutMin: currentEstimate.toMinAmount, + priceImpact: currentEstimate.impact, + errorType, + dieselStatus: estimate.dieselStatus, + estimates, + currentDexLabel: currentEstimate.dexLabel, + // Fees + networkFee: currentEstimate.networkFee, + realNetworkFee: currentEstimate.realNetworkFee, + swapFee: currentEstimate.swapFee, + swapFeePercent: currentEstimate.swapFeePercent, + ourFee: currentEstimate.ourFee, + ourFeePercent: estimate.ourFeePercent, + dieselFee: currentEstimate.dieselFee, + }; +} - global = updateCurrentSwap(global, { - shouldEstimate: false, - isEstimating: shouldBlock, - }); - setGlobal(global); +async function estimateCexSwap(global: GlobalState, shouldStop: () => boolean): Promise { + const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; + const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; - const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; - const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; + const from = resolveSwapAssetId(tokenIn); + const to = resolveSwapAssetId(tokenOut); + const fromAmount = global.currentSwap.amountIn ?? '0'; + const swapType = selectSwapType(global); - const from = resolveSwapAssetId(tokenIn); - const to = resolveSwapAssetId(tokenOut); - const fromAmount = global.currentSwap.amountIn ?? '0'; + const estimate = await callApi('swapCexEstimate', { + fromAmount, + from, + to, + }); - const estimate = await callApi('swapCexEstimate', { - fromAmount, - from, - to, - }); + if (shouldStop()) return undefined; - global = getGlobal(); - if (shouldAvoidEstimation(global)) return; + global = getGlobal(); - if (!estimate || 'errors' in estimate) { - global = updateCurrentSwap(global, { - ...resetParams, - errorType: window.navigator.onLine ? SwapErrorType.InvalidPair : SwapErrorType.UnexpectedError, - }); - setGlobal(global); - return; - } + if (!estimate) { + return { + ...getSwapEstimateResetParams(global), + errorType: window.navigator.onLine ? SwapErrorType.InvalidPair : SwapErrorType.UnexpectedError, + }; + } - if ('errors' in estimate) { - global = updateCurrentSwap(global, { - ...resetParams, - errorType: SwapErrorType.UnexpectedError, - }); - setGlobal(global); - return; + if ('error' in estimate) { + const { error } = estimate as { error: string }; + if (error.includes('requests limit')) { + return 'rateLimited'; } - if ('error' in estimate) { - const { error } = estimate as { error: string }; - if (error.includes('requests limit')) return; - - if (error.includes('Invalid amount')) { - const [, mode, matchedAmount] = error.match(/(Maximum|Minimal) amount is ([\d.]+) .*/) || []; - if (mode && matchedAmount) { - const isLessThanMin = mode === 'Minimal'; - global = updateCurrentSwap(global, { - ...resetParams, - limits: isLessThanMin ? { fromMin: matchedAmount } : { fromMax: matchedAmount }, - errorType: isLessThanMin ? SwapErrorType.ChangellyMinSwap : SwapErrorType.ChangellyMaxSwap, - }); - setGlobal(global); - return; - } - } - - global = updateCurrentSwap(global, { - ...resetParams, - errorType: SwapErrorType.UnexpectedError, - }); - setGlobal(global); - return; - } + return { + ...getSwapEstimateResetParams(global), + errorType: SwapErrorType.UnexpectedError, + }; + } - // Check for outdated response - if ( - (global.currentSwap.inputSource === SwapInputSource.In - && global.currentSwap.amountIn !== estimate.fromAmount) - || (global.currentSwap.inputSource === SwapInputSource.Out - && global.currentSwap.amountOut !== estimate.toAmount) - ) { - global = updateCurrentSwap(global, resetParams); - setGlobal(global); - return; - } + const account = global.accounts?.byId[global.currentAccountId!]; + let networkFee: string | undefined; + let realNetworkFee: string | undefined; - const account = global.accounts?.byId[global.currentAccountId!]; - let networkFee: string | undefined; - let realNetworkFee: string | undefined; - - if ( - global.currentSwap.swapType === SwapType.CrosschainFromWallet - && (tokenIn.chain === 'ton' || tokenIn.chain === 'tron') - ) { - const toAddress = { - ton: account?.addressByChain.ton!, - tron: TRX_SWAP_COUNT_FEE_ADDRESS, - }[tokenIn.chain]; - - const txDraft = await callApi('checkTransactionDraft', tokenIn.chain, { - accountId: global.currentAccountId!, - toAddress, - tokenAddress: tokenIn.tokenAddress, - }); - if (txDraft) { - ({ networkFee, realNetworkFee } = convertTransferFeesToSwapFees(txDraft, tokenIn.chain)); - } + if (swapType === SwapType.CrosschainFromWallet) { + if (tokenIn.chain !== 'ton' && tokenIn.chain !== 'tron') { + throw new Error(`Unexpected chain ${tokenIn.chain}`); } - global = getGlobal(); - if (shouldAvoidEstimation(global)) return; + const toAddress = { + ton: account?.addressByChain.ton!, + tron: TRX_SWAP_COUNT_FEE_ADDRESS, + }[tokenIn.chain]; - global = updateCurrentSwap(global, { - ...resetParams, - amountOut: estimate.toAmount === '0' ? undefined : estimate.toAmount, - limits: { - fromMin: estimate.fromMin, - fromMax: estimate.fromMax, - }, - swapFee: estimate.swapFee, - networkFee, - realNetworkFee, - ourFee: '0', - ourFeePercent: 0, - dieselStatus: 'not-available', - amountOutMin: estimate.toAmount, - isEstimating: false, - errorType: Big(fromAmount).lt(estimate.fromMin) - ? SwapErrorType.ChangellyMinSwap - : Big(fromAmount).gt(estimate.fromMax) - ? SwapErrorType.ChangellyMaxSwap - : undefined, + const txDraft = await callApi('checkTransactionDraft', tokenIn.chain, { + accountId: global.currentAccountId!, + toAddress, + tokenAddress: tokenIn.tokenAddress, }); - setGlobal(global); - } finally { - isEstimateCexSwapBeingExecuted = false; + if (txDraft) { + ({ networkFee, realNetworkFee } = convertTransferFeesToSwapFees(txDraft, tokenIn.chain)); + } } -}); + + return { + ...getSwapEstimateResetParams(global), + amountOut: estimate.toAmount === '0' ? undefined : estimate.toAmount, + limits: { + fromMin: estimate.fromMin, + fromMax: estimate.fromMax, + }, + swapFee: estimate.swapFee, + networkFee, + realNetworkFee, + ourFee: '0', + ourFeePercent: 0, + dieselStatus: 'not-available', + amountOutMin: estimate.toAmount, + errorType: Big(fromAmount).lt(estimate.fromMin) + ? SwapErrorType.ChangellyMinSwap + : Big(fromAmount).gt(estimate.fromMax) + ? SwapErrorType.ChangellyMaxSwap + : undefined, + }; +} addActionHandler('setSwapScreen', (global, actions, { state }) => { if (state === SwapState.Initial) { @@ -982,11 +757,6 @@ addActionHandler('clearSwapError', (global) => { setGlobal(global); }); -addActionHandler('setSwapType', (global, actions, { type }) => { - global = updateCurrentSwap(global, { swapType: type }); - setGlobal(global); -}); - addActionHandler('loadSwapPairs', async (global, actions, { tokenSlug, shouldForceUpdate }) => { const tokenIn = global.swapTokenInfo?.bySlug[tokenSlug]; if (!tokenIn) { @@ -1047,7 +817,6 @@ addActionHandler('loadSwapPairs', async (global, actions, { tokenSlug, shouldFor [tokenSlug]: bySlug, }, }, - shouldEstimate: true, }); setGlobal(global); }); @@ -1131,7 +900,7 @@ addActionHandler('setSwapDex', (global, actions, { dexLabel }) => { priceImpact: newEstimate.impact, currentDexLabel: dexLabel, isDexLabelChanged: true, - }); + }, true); setGlobal(global); }); @@ -1153,13 +922,71 @@ function convertTransferFeesToSwapFees( return { networkFee, realNetworkFee }; } -function shouldAvoidEstimation(global: GlobalState) { - // For a better UX, we should leave the fees and the other swap data intact during swap confirmations (for example, - // to avoid switching from/to gasless mode). - return ( - global.currentSwap.state === SwapState.Blockchain - || global.currentSwap.state === SwapState.Password - ); +type SwapEstimateResult = Partial | 'rateLimited' | void; + +let isEstimatingSwap = false; + +/** + * A boilerplate of swap estimation, ensuring consistent behavior in concurrent usage scenarios. + * This function is expected to be called periodically, and you may call it as often as you like. + * + * You may call the `shouldStop` function to check whether it makes sense to continue estimating (because the result + * is likely to be ignored). If `shouldStop` returns true, `estimate` may return any value (it will be ignored). + */ +async function estimateSwapConcurrently( + estimate: ( + global: GlobalState, + shouldStop: () => boolean, + ) => SwapEstimateResult | Promise, +) { + let initialGlobal = getGlobal(); + + if (shouldAvoidSwapEstimation(initialGlobal)) return; + + // Turning on the loading indicator even if another "hidden" estimation is already in progress. + // Keeping the `shouldEstimate` equal `true` in the state to handle the state properly after `estimate` finishes. + if (initialGlobal.currentSwap.shouldEstimate && !initialGlobal.currentSwap.isEstimating) { + initialGlobal = updateCurrentSwap(initialGlobal, { isEstimating: true }); + setGlobal(initialGlobal); + } + + // There should be only 1 swap estimation at a time. A timer in SwapInitial will trigger another estimation attempt. + if (isEstimatingSwap) { + return; + } + + try { + isEstimatingSwap = true; + + const isEstimateInputIntact = isSwapEstimateInputEqual.bind(undefined, initialGlobal); + + const swapUpdate = await estimate(initialGlobal, () => { + const currentGlobal = getGlobal(); + return shouldAvoidSwapEstimation(currentGlobal) || !isEstimateInputIntact(currentGlobal); + }); + + const finalGlobal = getGlobal(); + + // If the dependencies were changed during the estimation, the estimation result should be ignored and the loading + // indicator should stay (in order to avoid showing the outdated fee). A timer in SwapInitial will trigger another + // estimation attempt to get the up-to-date fee. + if (!isEstimateInputIntact(finalGlobal)) { + return; + } + + // If the swap estimation request has been rate-limited, we should keep showing the loading indicator + if (swapUpdate === 'rateLimited') { + return; + } + + setGlobal(updateCurrentSwap(finalGlobal, { + isEstimating: false, + shouldEstimate: false, + ...(shouldAvoidSwapEstimation(finalGlobal) ? undefined : swapUpdate), + })); + } finally { + isEstimatingSwap = false; + } } function chooseSwapEstimate( diff --git a/src/global/actions/apiUpdates/dapp.ts b/src/global/actions/apiUpdates/dapp.ts index fe6b4fb2..dadaa8fa 100644 --- a/src/global/actions/apiUpdates/dapp.ts +++ b/src/global/actions/apiUpdates/dapp.ts @@ -175,7 +175,7 @@ addActionHandler('apiUpdate', (global, actions, update) => { case 'processDeeplink': { const { url } = update; - processDeeplink(url); + void processDeeplink(url); break; } } diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 1a08c5ac..9e919ecd 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -1,6 +1,5 @@ import { BarcodeScanner } from '@capacitor-mlkit/barcode-scanning'; -import type { ApiChain } from '../../../api/types'; import type { LedgerTransport } from '../../../util/ledger/types'; import type { GlobalState } from '../../types'; import { @@ -12,13 +11,11 @@ import { APP_VERSION, BETA_URL, BOT_USERNAME, - CHAIN_CONFIG, DEBUG, IS_CAPACITOR, IS_EXTENSION, IS_PRODUCTION, PRODUCTION_URL, - TONCOIN, } from '../../../config'; import { ACCENT_BNW_INDEX, @@ -28,11 +25,9 @@ import { extractAccentColorIndex, } from '../../../util/accentColor'; import { vibrateOnSuccess } from '../../../util/capacitor'; -import { isTonDeeplink, parseTonDeeplink, processDeeplink } from '../../../util/deeplink'; +import { parseDeeplinkTransferParams, processDeeplink } from '../../../util/deeplink'; import { getCachedImageUrl } from '../../../util/getCachedImageUrl'; import getIsAppUpdateNeeded from '../../../util/getIsAppUpdateNeeded'; -import { isValidAddressOrDomain } from '../../../util/isValidAddressOrDomain'; -import { omitUndefined, pick } from '../../../util/iteratees'; import { getTranslation } from '../../../util/langProvider'; import { onLedgerTabClose, openLedgerTab } from '../../../util/ledger/tab'; import { callActionInMain, callActionInNative } from '../../../util/multitab'; @@ -46,12 +41,14 @@ import { IS_DELEGATING_BOTTOM_SHEET, } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; +import { parsePlainAddressQr } from '../../helpers/misc'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { clearCurrentSwap, clearCurrentTransfer, clearIsPinAccepted, renameAccount, + setCurrentTransferAddress, setIsPinAccepted, updateAccounts, updateAuth, @@ -66,12 +63,12 @@ import { selectCurrentAccount, selectCurrentAccountSettings, selectCurrentAccountState, - selectCurrentAccountTokens, selectFirstNonHardwareAccount, - selectIsMultichainAccount, } from '../../selectors'; import { switchAccount } from '../api/auth'; +import { getIsPortrait } from '../../../hooks/useDeviceScreen'; + import { reportAppLockActivityEvent } from '../../../components/AppLocked'; import { getCardNftImageUrl } from '../../../components/main/sections/Card/helpers/getCardNftImageUrl'; import { closeModal } from '../../../components/ui/Modal'; @@ -581,56 +578,44 @@ addActionHandler('closeQrScanner', (global) => { }; }); -addActionHandler('handleQrCode', (global, actions, { data }) => { +addActionHandler('handleQrCode', async (global, actions, { data }) => { if (IS_DELEGATED_BOTTOM_SHEET) { callActionInMain('handleQrCode', { data }); - return undefined; + return; } const { currentTransfer, currentSwap } = global.currentQrScan || {}; - const isMultichain = selectIsMultichainAccount(global, global.currentAccountId!); if (currentTransfer) { - const chainFromAddress = getChainFromAddress(data, isMultichain); - - if (chainFromAddress) { - const { tokenSlug } = currentTransfer; - - const token = selectCurrentAccountTokens(global)?.find(({ slug }) => slug === tokenSlug); - - const newTokenSlug = (!token || token.chain !== chainFromAddress) - ? CHAIN_CONFIG[chainFromAddress].nativeToken.slug - : tokenSlug; + const linkParams = parseDeeplinkTransferParams(data); + const toAddress = linkParams?.toAddress ?? data; + setGlobal(setCurrentTransferAddress(updateCurrentTransfer(global, currentTransfer), toAddress)); + return; + } - return updateCurrentTransfer(global, { - ...currentTransfer, - tokenSlug: newTokenSlug, - toAddress: data, - }); - } + if (currentSwap) { + const linkParams = parseDeeplinkTransferParams(data); + const toAddress = linkParams?.toAddress ?? data; + setGlobal(updateCurrentSwap(global, { ...currentSwap, toAddress })); + return; + } - const linkParams = isTonDeeplink(data) ? parseTonDeeplink(data) : undefined; - if (linkParams) { - return updateCurrentTransfer(global, { - ...currentTransfer, - // For NFT transfer we only extract address from a ton:// link - ...(currentTransfer.nfts?.length ? pick(linkParams, ['toAddress']) : omitUndefined(linkParams)), - // Only Toncoin can be processed with deeplink right now - tokenSlug: TONCOIN.slug, - }); - } + if (await processDeeplink(data)) { + return; } - if (currentSwap) { - return updateCurrentSwap(global, { - ...currentSwap, - toAddress: data, + global = getGlobal(); + + const plainAddressData = parsePlainAddressQr(global, data); + if (plainAddressData) { + actions.startTransfer({ + ...plainAddressData, + isPortrait: getIsPortrait(), }); + return; } - processDeeplink(data); - - return undefined; + actions.showDialog({ title: 'This QR Code is not supported', message: '' }); }); addActionHandler('changeBaseCurrency', async (global, actions, { currency }) => { @@ -855,9 +840,3 @@ async function connectLedgerAndGetHardwareState() { return newHardwareState; } - -function getChainFromAddress(address: string, isMultichainAccount: boolean): ApiChain | undefined { - if (isMultichainAccount && isValidAddressOrDomain(address, 'tron')) return 'tron'; - - return isValidAddressOrDomain(address, 'ton') ? 'ton' : undefined; -} diff --git a/src/global/actions/ui/transfer.ts b/src/global/actions/ui/transfer.ts index 67267839..a51445b2 100644 --- a/src/global/actions/ui/transfer.ts +++ b/src/global/actions/ui/transfer.ts @@ -4,7 +4,7 @@ import { fromDecimal, toDecimal } from '../../../util/decimals'; import { callActionInMain } from '../../../util/multitab'; import { IS_DELEGATED_BOTTOM_SHEET } from '../../../util/windowEnvironment'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; -import { updateCurrentTransfer } from '../../reducers'; +import { setCurrentTransferAddress, updateCurrentTransfer } from '../../reducers'; import { selectAccount } from '../../selectors'; addActionHandler('startTransfer', (global, actions, payload) => { @@ -62,39 +62,23 @@ addActionHandler('changeTransferToken', (global, actions, { tokenSlug, withReset addActionHandler('setTransferScreen', (global, actions, payload) => { const { state } = payload; - setGlobal(updateCurrentTransfer(global, { state })); + return updateCurrentTransfer(global, { state }); }); addActionHandler('setTransferAmount', (global, actions, { amount }) => { - setGlobal( - updateCurrentTransfer(global, { - amount, - }), - ); + return updateCurrentTransfer(global, { amount }); }); addActionHandler('setTransferToAddress', (global, actions, { toAddress }) => { - setGlobal( - updateCurrentTransfer(global, { - toAddress, - }), - ); + return setCurrentTransferAddress(global, toAddress); }); addActionHandler('setTransferComment', (global, actions, { comment }) => { - setGlobal( - updateCurrentTransfer(global, { - comment, - }), - ); + return updateCurrentTransfer(global, { comment }); }); addActionHandler('setTransferShouldEncrypt', (global, actions, { shouldEncrypt }) => { - setGlobal( - updateCurrentTransfer(global, { - shouldEncrypt, - }), - ); + return updateCurrentTransfer(global, { shouldEncrypt }); }); addActionHandler('submitTransferConfirm', (global, actions) => { diff --git a/src/global/cache.ts b/src/global/cache.ts index 8ba3ca36..6c223cbc 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -584,17 +584,14 @@ function reduceAccountActivities(activities?: AccountState['activities'], tokens } function reduceAccountStaking(staking?: AccountState['staking']) { - let stateById = staking?.stateById; - let stakingId = staking?.stakingId; + let { stakingId, stateById } = staking ?? {}; - if (!staking || !stateById || !Object.keys(stateById).length) { - return undefined; - } - - stateById = filterValues(stateById, getIsActiveStakingState); + if (stateById && Object.values(stateById).length) { + stateById = filterValues(stateById, getIsActiveStakingState); - if (!stakingId || !(stakingId in stateById)) { - stakingId = Object.values(stateById)[0]?.id; + if (!stakingId || !(stakingId in stateById)) { + stakingId = Object.values(stateById)[0]?.id; + } } return { diff --git a/src/global/helpers/misc.ts b/src/global/helpers/misc.ts new file mode 100644 index 00000000..2933b9f2 --- /dev/null +++ b/src/global/helpers/misc.ts @@ -0,0 +1,37 @@ +import type { ApiChain } from '../../api/types'; +import type { GlobalState } from '../types'; + +import { isValidAddressOrDomain } from '../../util/isValidAddressOrDomain'; +import { getChainBySlug, getNativeToken } from '../../util/tokens'; +import { selectCurrentAccount } from '../selectors'; + +/** + * Parses the transfer parameters from the given QR content, assuming it's a plain address. + * Returns `undefined` if this is not a valid address or the account doesn't have the corresponding wallet. + */ +export function parsePlainAddressQr(global: GlobalState, qrData: string) { + const availableChains = selectCurrentAccount(global)?.addressByChain ?? ({} as Record); + const newChain = getChainFromAddress(qrData, availableChains); + if (!newChain) { + return undefined; + } + + const currentTokenSlug = global.currentTransfer.tokenSlug; + const currentChain = getChainBySlug(currentTokenSlug); + const newTokenSlug = newChain !== currentChain ? getNativeToken(newChain).slug : currentTokenSlug; + + return { + toAddress: qrData, + tokenSlug: newTokenSlug, + }; +} + +function getChainFromAddress(address: string, availableChains: Record): ApiChain | undefined { + for (const chain of Object.keys(availableChains) as Array) { + if (isValidAddressOrDomain(address, chain)) { + return chain; + } + } + + return undefined; +} diff --git a/src/global/helpers/staking.ts b/src/global/helpers/staking.ts index d4cd8e85..0aa31881 100644 --- a/src/global/helpers/staking.ts +++ b/src/global/helpers/staking.ts @@ -10,13 +10,14 @@ export function buildStakingDropdownItems({ states: ApiStakingState[]; shouldUseNominators?: boolean; }) { - if (!shouldUseNominators) { - shouldUseNominators = states.some((state) => state.type === 'nominators' && state.balance); - } + const hasNominatorsStake = states.some((state) => state.type === 'nominators' && state.balance); + const hasLiquidStake = states.some((state) => state.type === 'liquid' && state.balance); - if (shouldUseNominators) { + if (shouldUseNominators && !hasLiquidStake) { states = states.filter((state) => state.type !== 'liquid'); - } else { + } + + if (!shouldUseNominators && !hasNominatorsStake) { states = states.filter((state) => state.type !== 'nominators'); } diff --git a/src/global/helpers/swap.ts b/src/global/helpers/swap.ts new file mode 100644 index 00000000..8579e244 --- /dev/null +++ b/src/global/helpers/swap.ts @@ -0,0 +1,91 @@ +import type { GlobalState } from '../types'; +import { SwapInputSource, SwapState } from '../types'; + +export function shouldAvoidSwapEstimation(global: GlobalState) { + // For a better UX, we should leave the fees and the other swap data intact during swap confirmation (for example, + // to avoid switching from/to gasless mode). + // `shouldEstimate` forces estimation, because normally it means that there was a swap parameter change that + // invalidates the current swap estimation. + return !global.currentSwap.shouldEstimate && ( + global.currentSwap.state === SwapState.Blockchain + || global.currentSwap.state === SwapState.Password + ); +} + +/** + * Returns true if the swap estimate prepared for the global 1 is suitable for the global 2 + */ +export function isSwapEstimateInputEqual({ currentSwap: swap1 }: GlobalState, { currentSwap: swap2 }: GlobalState) { + const amountKey = swap1.inputSource === SwapInputSource.In ? 'amountIn' : 'amountOut'; + + return swap1.tokenInSlug === swap2.tokenInSlug + && swap1.tokenOutSlug === swap2.tokenOutSlug + && swap1.slippage === swap2.slippage + && swap1.inputSource === swap2.inputSource + && swap1.isMaxAmount === swap2.isMaxAmount + && (swap2.isMaxAmount || swap1[amountKey] === swap2[amountKey]); +} + +/** + * Returns true is the swap form has enough data to start estimation + */ +export function isSwapFormFilled({ currentSwap }: GlobalState) { + const amountKey = currentSwap.inputSource === SwapInputSource.In ? 'amountIn' : 'amountOut'; + + return currentSwap.tokenInSlug + && currentSwap.tokenOutSlug + && Number(currentSwap[amountKey] ?? '0') > 0; // The backend fails if the amount is "0", "0.0", etc +} + +export function doesSwapChangeRequireEstimation(globalBefore: GlobalState, globalAfter: GlobalState) { + return isSwapFormFilled(globalAfter) && ( + !isSwapEstimateInputEqual(globalBefore, globalAfter) + || globalBefore.currentSwap.pairs !== globalAfter.currentSwap.pairs + ); +} + +export function doesSwapChangeRequireEstimationReset(globalBefore: GlobalState, globalAfter: GlobalState) { + return !isSwapFormFilled(globalAfter) + || globalBefore.currentSwap.tokenInSlug !== globalAfter.currentSwap.tokenInSlug + || globalBefore.currentSwap.tokenOutSlug !== globalAfter.currentSwap.tokenOutSlug; +} + +export function doesSwapChangeRequireDexUnselect( + { currentSwap: swap1 }: GlobalState, + { currentSwap: swap2 }: GlobalState, +) { + const amountKey = swap1.inputSource === SwapInputSource.In ? 'amountIn' : 'amountOut'; + + return swap1.tokenInSlug !== swap2.tokenInSlug + || swap1.tokenOutSlug !== swap2.tokenOutSlug + || swap1.inputSource !== swap2.inputSource + || swap1[amountKey] !== swap2[amountKey]; +} + +/** + * Returns the `currentSwap` parameters that should be set when it's impossible to estimate the current swap or no + * estimation has been done. + */ +export function getSwapEstimateResetParams(global: GlobalState) { + const amountReset = global.currentSwap.inputSource === SwapInputSource.In + ? { amountOut: undefined } + : { amountIn: undefined }; + + return { + ...amountReset, + amountOutMin: '0', + priceImpact: 0, + errorType: undefined, + limits: undefined, + dieselStatus: undefined, + estimates: undefined, + bestRateDexLabel: undefined, + networkFee: undefined, + realNetworkFee: undefined, + swapFee: undefined, + swapFeePercent: undefined, + ourFee: undefined, + ourFeePercent: undefined, + dieselFee: undefined, + } satisfies Partial; +} diff --git a/src/global/reducers/swap.ts b/src/global/reducers/swap.ts index 458d75ba..1a81246c 100644 --- a/src/global/reducers/swap.ts +++ b/src/global/reducers/swap.ts @@ -2,8 +2,14 @@ import type { GlobalState } from '../types'; import { SwapState } from '../types'; import { DEFAULT_SLIPPAGE_VALUE } from '../../config'; +import { + doesSwapChangeRequireDexUnselect, + doesSwapChangeRequireEstimation, + doesSwapChangeRequireEstimationReset, + getSwapEstimateResetParams, +} from '../helpers/swap'; -export function updateCurrentSwap(global: GlobalState, update: Partial) { +function rawUpdateCurrentSwap(global: GlobalState, update: Partial) { return { ...global, currentSwap: { @@ -13,6 +19,37 @@ export function updateCurrentSwap(global: GlobalState, update: Partial, + // Set to true if you want to not trigger the swap estimation, and you are sure estimation is not needed + doAvoidEstimation?: boolean, +) { + let newGlobal = rawUpdateCurrentSwap(global, update); + + if (!doAvoidEstimation) { + if (doesSwapChangeRequireEstimationReset(global, newGlobal)) { + newGlobal = rawUpdateCurrentSwap(newGlobal, getSwapEstimateResetParams(newGlobal)); + } + + if (doesSwapChangeRequireEstimation(global, newGlobal)) { + newGlobal = rawUpdateCurrentSwap(newGlobal, { + shouldEstimate: true, + isEstimating: true, // Setting this is not necessary, but it allows to avoid one state update through the UI + }); + } + } + + if (doesSwapChangeRequireDexUnselect(global, newGlobal)) { + newGlobal = rawUpdateCurrentSwap(newGlobal, { + isDexLabelChanged: undefined, + }); + } + + // Applying the update again because the input fields should have a higher priority than the above automatic updates + return rawUpdateCurrentSwap(newGlobal, update); +} + export function clearCurrentSwap(global: GlobalState) { return { ...global, diff --git a/src/global/reducers/transfer.ts b/src/global/reducers/transfer.ts index e6869680..5af55989 100644 --- a/src/global/reducers/transfer.ts +++ b/src/global/reducers/transfer.ts @@ -3,7 +3,7 @@ import type { GlobalState } from '../types'; import { pick } from '../../util/iteratees'; import { INITIAL_STATE } from '../initialState'; -import { selectCurrentTransferMaxAmount } from '../selectors'; +import { selectCurrentTransferMaxAmount, selectTokenMatchingCurrentTransferAddressSlow } from '../selectors'; export function updateCurrentTransferByCheckResult(global: GlobalState, result: ApiCheckTransactionDraftResult) { const nextGlobal = updateCurrentTransfer(global, { @@ -53,3 +53,16 @@ export function updateCurrentTransferLoading(global: GlobalState, isLoading: boo }, }; } + +export function setCurrentTransferAddress(global: GlobalState, toAddress: string | undefined) { + global = updateCurrentTransfer(global, { toAddress }); + + // Unless the user has filled the amount, the token should change to match the "to" address + if (!global.currentTransfer.amount) { + global = updateCurrentTransfer(global, { + tokenSlug: selectTokenMatchingCurrentTransferAddressSlow(global), + }); + } + + return global; +} diff --git a/src/global/selectors/staking.ts b/src/global/selectors/staking.ts index 9c39bd94..97571047 100644 --- a/src/global/selectors/staking.ts +++ b/src/global/selectors/staking.ts @@ -1,7 +1,7 @@ import type { ApiStakingState } from '../../api/types'; import type { GlobalState } from '../types'; -import { TONCOIN } from '../../config'; +import { DEFAULT_NOMINATORS_STAKING_STATE, TONCOIN } from '../../config'; import memoize from '../../util/memoize'; import withCache from '../../util/withCache'; import { selectAccountState } from './accounts'; @@ -21,9 +21,10 @@ export function selectAccountStakingStates(global: GlobalState, accountId: strin } export function selectAccountStakingState(global: GlobalState, accountId: string): ApiStakingState { - const { stateById, stakingId } = selectAccountState(global, accountId)?.staking ?? {}; + const { stateById, stakingId, shouldUseNominators } = selectAccountState(global, accountId)?.staking ?? {}; + if (!stateById || !stakingId || !(stakingId in stateById)) { - return global.stakingDefault; + return shouldUseNominators ? DEFAULT_NOMINATORS_STAKING_STATE : global.stakingDefault; } return stateById[stakingId]; diff --git a/src/global/selectors/swap.ts b/src/global/selectors/swap.ts index 07bf6997..2bf2edfa 100644 --- a/src/global/selectors/swap.ts +++ b/src/global/selectors/swap.ts @@ -1,10 +1,13 @@ import type { ApiBalanceBySlug, ApiSwapAsset } from '../../api/types'; import type { AccountSettings, GlobalState, UserSwapToken } from '../types'; +import { SwapType } from '../types'; +import { DEFAULT_SWAP_FISRT_TOKEN_SLUG, DEFAULT_SWAP_SECOND_TOKEN_SLUG } from '../../config'; import { toBig } from '../../util/decimals'; import memoize from '../../util/memoize'; +import { getChainBySlug } from '../../util/tokens'; import withCache from '../../util/withCache'; -import { selectCurrentAccountSettings, selectCurrentAccountState } from './accounts'; +import { selectCurrentAccount, selectCurrentAccountSettings, selectCurrentAccountState } from './accounts'; import { selectAccountTokensMemoizedFor } from './tokens'; function createTokenList( @@ -146,3 +149,24 @@ export function selectCurrentSwapTokenOut(global: GlobalState) { const { tokenOutSlug } = global.currentSwap; return tokenOutSlug === undefined ? undefined : global.swapTokenInfo.bySlug[tokenOutSlug]; } + +export function selectSwapType(global: GlobalState) { + const { + tokenInSlug = DEFAULT_SWAP_FISRT_TOKEN_SLUG, + tokenOutSlug = DEFAULT_SWAP_SECOND_TOKEN_SLUG, + } = global.currentSwap; + const tokenInChain = getChainBySlug(tokenInSlug); + const tokenOutChain = getChainBySlug(tokenOutSlug); + + if (tokenInChain === 'ton' && tokenOutChain === 'ton') { + return SwapType.OnChain; + } + + const { addressByChain } = selectCurrentAccount(global) ?? { addressByChain: {} }; + + if (tokenInChain in addressByChain) { + return SwapType.CrosschainFromWallet; + } + + return SwapType.CrosschainToWallet; +} diff --git a/src/global/selectors/tokens.ts b/src/global/selectors/tokens.ts index e0c33655..608711fd 100644 --- a/src/global/selectors/tokens.ts +++ b/src/global/selectors/tokens.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { ApiBalanceBySlug } from '../../api/types'; +import type { ApiBalanceBySlug, ApiChain } from '../../api/types'; import type { AccountSettings, GlobalState, UserToken } from '../types'; import { @@ -144,3 +144,14 @@ export function selectMycoin(global: GlobalState) { export function selectTokenByMinterAddress(global: GlobalState, minter: string) { return Object.values(global.tokenInfo.bySlug).find((token) => token.tokenAddress === minter); } + +export function selectChainTokenWithMaxBalanceSlow(global: GlobalState, chain: ApiChain): UserToken | undefined { + return (selectCurrentAccountTokens(global) ?? []) + .filter((token) => token.chain === chain) + .reduce((maxToken, currentToken) => { + const currentBalance = currentToken.priceUsd * Number(currentToken.amount); + const maxBalance = maxToken ? maxToken.priceUsd * Number(maxToken.amount) : 0; + + return currentBalance > maxBalance ? currentToken : maxToken; + }); +} diff --git a/src/global/selectors/transfer.ts b/src/global/selectors/transfer.ts index 4890fb73..4e3eb4fa 100644 --- a/src/global/selectors/transfer.ts +++ b/src/global/selectors/transfer.ts @@ -1,7 +1,10 @@ import type { GlobalState } from '../types'; import { explainApiTransferFee, getMaxTransferAmount } from '../../util/fee/transferFee'; -import { selectCurrentAccountTokenBalance } from './tokens'; +import { isValidAddressOrDomain } from '../../util/isValidAddressOrDomain'; +import { getChainBySlug } from '../../util/tokens'; +import { selectCurrentAccount } from './accounts'; +import { selectChainTokenWithMaxBalanceSlow, selectCurrentAccountTokenBalance } from './tokens'; export function selectCurrentTransferMaxAmount(global: GlobalState) { const { currentTransfer } = global; @@ -14,3 +17,40 @@ export function selectCurrentTransferMaxAmount(global: GlobalState) { canTransferFullBalance, }); } + +/** + * Returns the token slug that should be set to current transfer form to keep the token in sync with the "to" address + */ +export function selectTokenMatchingCurrentTransferAddressSlow(global: GlobalState): string { + const { tokenSlug: currentTokenSlug, toAddress } = global.currentTransfer; + const currentChain = getChainBySlug(currentTokenSlug); + + if (!toAddress) { + return currentTokenSlug; + } + + // First try to match a chain by the full address, then by the prefix. + // Because a valid TRON address is a prefix of a valid TON address, and we want to match TRON in this case. + for (const isCheckingPrefix of [false, true]) { + // If the current token already matches the address, no need to change it + if (!toAddress || isValidAddressOrDomain(toAddress, currentChain, isCheckingPrefix)) { + return currentTokenSlug; + } + + // Otherwise, find the best token of the address's chain + const availableChains = selectCurrentAccount(global)?.addressByChain; + if (availableChains) { + for (const chain of Object.keys(availableChains) as Array) { + if (!isValidAddressOrDomain(toAddress, chain, isCheckingPrefix)) { + continue; + } + + const token = selectChainTokenWithMaxBalanceSlow(global, chain); + if (token) return token.slug; + } + } + } + + // If the address matches no available chain, don't change the selected token + return currentTokenSlug; +} diff --git a/src/global/types.ts b/src/global/types.ts index 8da42960..6c7947ca 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -490,10 +490,10 @@ export type GlobalState = { errorType?: SwapErrorType; shouldResetOnClose?: boolean; isLoading?: boolean; + // When set to `true`, the next swap estimation will show the loading indicator and will block the form shouldEstimate?: boolean; isEstimating?: boolean; inputSource?: SwapInputSource; - swapType?: SwapType; toAddress?: string; payinAddress?: string; payoutAddress?: string; @@ -990,18 +990,15 @@ export interface ActionPayloads { switchSwapTokens: undefined; setSwapTokenIn: { tokenSlug: string }; setSwapTokenOut: { tokenSlug: string }; - setSwapAmountIn: { amount?: string }; - setSwapIsMaxAmount: { isMaxAmount?: boolean }; + setSwapAmountIn: { amount?: string; isMaxAmount?: boolean }; setSwapAmountOut: { amount?: string }; setSlippage: { slippage: number }; loadSwapPairs: { tokenSlug: string; shouldForceUpdate?: boolean }; clearSwapPairsCache: undefined; - estimateSwap: { shouldBlock: boolean }; + estimateSwap: undefined; setSwapScreen: { state: SwapState }; clearSwapError: undefined; - estimateSwapCex: { shouldBlock: boolean }; submitSwapCex: { password: string }; - setSwapType: { type: SwapType }; setSwapCexAddress: { toAddress: string }; addSwapToken: { token: UserSwapToken }; toggleSwapSettingsModal: { isOpen: boolean }; diff --git a/src/i18n/de.yaml b/src/i18n/de.yaml index 66dc88a0..0508ca09 100644 --- a/src/i18n/de.yaml +++ b/src/i18n/de.yaml @@ -7,8 +7,8 @@ Import From %1$d Secret Words: Importieren von %1$d Geheimwörtern More about MyTonWallet: Mehr über MyTonWallet Creating Wallet...: Wallet erstellen... On the count of three...: Auf drei zählen... -$back_up_auth: Back Up -$back_up_security: Back Up +$back_up_auth: Sicherung +$back_up_security: Sicherung Passwords must be equal.: Die Passwörter müssen gleich sein. To protect your wallet as much as possible, use a password with: Um Ihre Wallet so gut wie möglich zu schützen, verwenden Sie ein Passwort mit $auth_password_rule_8chars: mindestens 8 Zeichen @@ -112,18 +112,16 @@ $logout_without_backup_warning: Sie haben diese Wallet nicht gesichert. Wenn Sie $logout_accounts_without_backup_warning: Sie haben %links% nicht gesichert. Wenn Sie fortfahren, verlieren Sie den Zugriff auf Ihre Token und NFTs. $logout_warning: Dies wird das aktuelle Wallet aus der App entfernen. Stellen Sie sicher, dass Sie Ihre **Geheimwörter** gesichert haben. Your address was copied!: Ihre Adresse wurde kopiert! -Show QR Code: QR-Code anzeigen Create Deposit Link: Einzahlungslink erstellen -QR Code: QR-Code $receive_invoice_description: | Sie können den Betrag und den Zweck der Zahlung angeben, um dem Sender Zeit zu sparen. Amount: Betrag Comment: Memo -Comment or Memo: Memo +Comment or Memo: Kommentar oder Memo Share this URL to receive TON: Teilen Sie diese URL, um TON zu empfangen Invoice link was copied!: Die Rechnungslink wurde kopiert! -Theme: Design +Theme: Thema Language: Sprache Enable Animations: Animationen aktivieren Base Currency: Basiswährung @@ -131,7 +129,7 @@ Investor View: Investorenansicht Toggle Investor View: Investorenansicht umschalten Hide Tiny Transfers: Kleine Überweisungen ausblenden Toggle Hide Tiny Transfers: Kleine Überweisungen ausblenden umschalten -Network: Network +Network: Netzwerk Developer Options: Entwickleroptionen Confirmation: Bestätigung Signing Data: Signierdaten @@ -276,15 +274,15 @@ Connect: Verbinden Dapp Permissions: Dapp-Berechtigungen $dapp_can_view_balance: "%dappname% wird die Wallet adresse und den Kontostand anzeigen können." Send Transaction: Transaktion senden -Payload: Payload +Payload: Nutzlast $many_transactions: "%1$d Transaktionen" Total Amount: Gesamtbetrag Unstaking: Entstaken -Handle ton:// links: Handle ton:// links +Handle ton:// links: Umgang mit ton://-Links Back up wallet to have full access to it: Sichern Sie das Wallet, um vollen Zugriff darauf zu haben Consider More Secure Version: Berücksichtigen Sie eine sicherere Version Install our native app or browser extension.: Installieren Sie unsere native App oder Browser-Erweiterung. -Scam: Scam +Scam: Betrug Scam comment is hidden.: Betrügerischer Kommentar ist versteckt. Display: Anzeigen $dapp_transfer_tokens_payload: Senden von %amount% von Ihrem Konto an %address% @@ -354,7 +352,7 @@ Included: Inklusive Price Impact: Preisauswirkung Minimum Received: Mindestbetrag erhalten Slippage: Schlupf -$swap_from_to: Tauschen %from% %icon% %to%. +$swap_from_to: Tauschen %from% %icon% %to% The exchange rate is below market value!: Der Wechselkurs liegt %value% unter dem Marktwert! Invalid Pair: Ungültiges Paar We do not recommend to perform an exchange, try to specify a lower amount.: Wir empfehlen keine Durchführung des Austauschs. Versuchen Sie, einen niedrigeren Betrag anzugeben. @@ -463,9 +461,8 @@ A request is already pending: Es liegt bereits eine ausstehende Anfrage vor Please confirm operation using biometrics: Bitte bestätigen Sie die Operation mit Hilfe der Biometrie Scan QR Code: QR-Code scannen Permission denied. Please grant camera permission to use the QR code scanner.: Berechtigung verweigert. Bitte erteilen Sie die Kamera-Berechtigung, um den QR-Code-Scanner zu verwenden. -Unsupported QR code format: Nicht unterstütztes QR-Code-Format +This QR Code is not supported: Dieser QR-Code wird nicht unterstützt An error on the server side. Please try again.: Ein Fehler auf der Serverseite. Bitte versuchen Sie es erneut. -Unrecognized QR Code: Nicht erkannter QR-Code Address was saved!: Adresse wurde gespeichert! US Dollar: US-Dollar Euro: Euro diff --git a/src/i18n/en.yaml b/src/i18n/en.yaml index d0c0d82e..63242077 100644 --- a/src/i18n/en.yaml +++ b/src/i18n/en.yaml @@ -112,9 +112,7 @@ $logout_without_backup_warning: You have not backed up this wallet. If you remov $logout_accounts_without_backup_warning: You have not backed up %links%. If you proceed, you will lose access to your tokens and NFTs. $logout_warning: This will remove the current wallet from the app. Make sure you have your **secret words** backed up. Your address was copied!: Your address was copied! -Show QR Code: Show QR Code Create Deposit Link: Create Deposit Link -QR Code: QR Code $receive_invoice_description: | You can specify the amount and purpose of the payment to save the sender some time. @@ -462,9 +460,8 @@ A request is already pending: A request is already pending Please confirm operation using biometrics: Please confirm operation using biometrics Scan QR Code: Scan QR Code Permission denied. Please grant camera permission to use the QR code scanner.: Permission denied. Please grant camera permission to use the QR code scanner. -Unsupported QR code format: Unsupported QR code format +This QR Code is not supported: This QR Code is not supported An error on the server side. Please try again.: An error on the server side. Please try again. -Unrecognized QR Code: Unrecognized QR Code Address was saved!: Address was saved! US Dollar: US Dollar Euro: Euro diff --git a/src/i18n/es.yaml b/src/i18n/es.yaml index 9398878f..1957c4e3 100644 --- a/src/i18n/es.yaml +++ b/src/i18n/es.yaml @@ -112,9 +112,7 @@ $logout_without_backup_warning: No ha realizado copia de seguridad de este moned $logout_accounts_without_backup_warning: No ha realizado copia de seguridad de %links%. Si cierra sesión, perderá el acceso a sus activos. $logout_warning: Esto desconectará el monedero de esta aplicación. Podrá restaurar su monedero usando la frase semilla... Your address was copied!: ¡Su dirección fue copiada! -Show QR Code: Mostrar código QR Create Deposit Link: Crear enlace de depósito -QR Code: Código QR $receive_invoice_description: | Puede especificar la cantidad y el propósito del pago para ahorrar tiempo al remitente. @@ -463,9 +461,8 @@ A request is already pending: Ya hay una solicitud pendiente Please confirm operation using biometrics: Confirme la operación mediante datos biométricos Scan QR Code: Escanear código QR Permission denied. Please grant camera permission to use the QR code scanner.: Permiso denegado. Otorgue permiso a la cámara para usar el escáner QR. -Unsupported QR code format: Formato de código QR no soportado +This QR Code is not supported: Este código QR no es compatible An error on the server side. Please try again.: Un error en el lado del servidor. Inténtalo de nuevo. -Unrecognized QR Code: Código QR no reconocido Address was saved!: ¡Dirección guardada! US Dollar: Dólar estadounidense Euro: Euro diff --git a/src/i18n/pl.yaml b/src/i18n/pl.yaml index a886327c..255c94f0 100644 --- a/src/i18n/pl.yaml +++ b/src/i18n/pl.yaml @@ -114,9 +114,7 @@ $logout_without_backup_warning: Nie utworzono kopii zapasowej tego portfela. Je $logout_accounts_without_backup_warning: Nie utworzono kopii zapasowej %links%. Kontynuowanie spowoduje utratę dostępu do tokenów i NFT. $logout_warning: Spowoduje to usunięcie bieżącego portfela z aplikacji. Upewnij się, że masz kopię zapasową swoich **tajnych słów**. Your address was copied!: Twój adres został skopiowany! -Show QR Code: Pokaż kod QR Create Deposit Link: Utwórz łącze do wpłaty -QR Code: Kod QR $receive_invoice_description: | Możesz określić kwotę i cel płatności, aby zaoszczędzić czas nadawcy. @@ -219,7 +217,7 @@ Appearance: Wygląd Light: Jasny Dark: Ciemny System: Systemowy -$stake_asset: Stake %symbol% +$stake_asset: Stakuj %symbol% Earn from your tokens while holding them: Zarabiaj na swoich tokenach, trzymając je Why this is safe: Dlaczego to jest bezpieczne Why is staking safe?: Dlaczego staking jest bezpieczny? @@ -465,9 +463,8 @@ A request is already pending: Żądanie jest już oczekujące Please confirm operation using biometrics: Proszę potwierdzić operację za pomocą biometrii Scan QR Code: Zeskanuj kod QR Permission denied. Please grant camera permission to use the QR code scanner.: Odmowa uprawnień. Proszę udzielić uprawnień kamery, aby użyć skanera kodów QR. -Unsupported QR code format: Nieobsługiwany format kodu QR +This QR Code is not supported: Ten kod QR nie jest obsługiwany An error on the server side. Please try again.: Błąd po stronie serwera. Proszę spróbować ponownie. -Unrecognized QR Code: Nie rozpoznano kodu QR Address was saved!: Adres został zapisany! US Dollar: Dolar amerykański Euro: Euro @@ -644,7 +641,7 @@ Codes don’t match: Kody nie pasują do siebie Enter your new code: Wprowadź nowy kod Re-enter your new code: Wprowadź nowy kod ponownie MyTonWallet Features: Funkcje MyTonWallet -Install on Desktop: Zainstaluj na pulpicie +Install on Desktop: Zainstaluj na komputerze Install on Mobile: Zainstaluj na telefonie komórkowym Install App: Zainstaluj aplikację WrongNetwork: Dapp zażądał transakcji z innej sieci. diff --git a/src/i18n/ru.yaml b/src/i18n/ru.yaml index 76562d45..ec0c7ef1 100644 --- a/src/i18n/ru.yaml +++ b/src/i18n/ru.yaml @@ -115,9 +115,7 @@ $logout_accounts_without_backup_warning: Резервная копия не бы $logout_warning: Кошелёк будет удалён только **на этом устройстве**. Убедитесь, что у вас есть резервная копия ваших **секретных слов**. $logout_confirm: Выйти из **всех** кошельков Your address was copied!: Адрес скопирован! -Show QR Code: Показать QR-код Create Deposit Link: Ссылка на депозит -QR Code: QR-код $receive_invoice_description: | Укажите сумму и назначение платежа, если это необходимо. @@ -218,7 +216,7 @@ Appearance: Внешний вид Light: Светлая Dark: Тёмная System: Системная -$stake_asset: Стейкинг %symbol% +$stake_asset: Застейкать %symbol% Earn from your tokens while holding them: Получайте пассивный доход от хранения %symbol% на надёжном официальном смарт-контракте Est. %annual_yield%: Доходность %annual_yield% Why this is safe: Почему это безопасно @@ -227,7 +225,7 @@ $safe_staking_description1: Стейкинг **полностью децентр $safe_staking_description2: Ваш депозит будет использован для валидации транзакций в блокчейне в рамках механизма **proof-of-stake**. $safe_staking_description3: Вы можете вывести депозит с заработанными процентами **в любой момент** — средства переведутся на ваш основной счёт **в течение двух дней**. $safe_staking_description_jetton1: Стейкинг токенов является **полностью децентрализованным** и управляется **опенсорсными** смарт-контрактами, разработанными %jvault_link% и прошедшими **аудит безопасности**. -$safe_staking_description_jetton2: Вы можете вывести свои средства из стейкинга **в любое время**, и они будут **мгновенно** возвращены на ваш счет. +$safe_staking_description_jetton2: Вы можете вывести свои средства из стейкинга **в любое время**, и они **мгновенно** вернутся на ваш счет. $min_value: Мин. %value% Est. balance in a year: Ожидаемый баланс через год Confirm Staking: Подтвердить @@ -467,9 +465,8 @@ A request is already pending: Подключение биометрии уже Please confirm operation using biometrics: Пожалуйста, подтвердите операцию с помощью биометрии Scan QR Code: Сканировать QR-код Permission denied. Please grant camera permission to use the QR code scanner.: Доступ запрещён. Пожалуйста, дайте камере разрешение на использование сканера QR-кода. -Unsupported QR code format: Неподдерживаемый формат QR-кода +This QR Code is not supported: Этот QR-код не поддерживается An error on the server side. Please try again.: Ошибка на стороне сервера. Пожалуйста, попробуйте ещё раз. -Unrecognized QR Code: Нераспознанный QR-код Address was saved!: Адрес сохранён! US Dollar: Доллар США Euro: Евро diff --git a/src/i18n/th.yaml b/src/i18n/th.yaml index 0b3e762d..3e29b9ec 100644 --- a/src/i18n/th.yaml +++ b/src/i18n/th.yaml @@ -112,9 +112,7 @@ $logout_without_backup_warning: หากคุณไม่ได้สำรอ $logout_accounts_without_backup_warning: หากคุณไม่ได้สำรอง %links% ของคุณ. เมื่อคุณลบวอลเล็ตนี้, คุณจะไม่สามารถเข้าถึงโทเคนและ NFT ในวอลเล็ตนี้ได้อีกต่อไป. $logout_warning: การดำเนินการนี้จะลบกระเป๋าเงินปัจจุบันออกจากแอป ตรวจสอบให้แน่ใจว่าคุณได้สำรองข้อมูล **คำลับ** ของคุณแล้ว. Your address was copied!: ที่อยู่บัญชีของคุณถูกคัดลอกแล้ว! -Show QR Code: แสดงคิวอาร์โค้ด Create Deposit Link: สร้างลิงก์สำหรับการฝาก -QR Code: คิวอาร์โค้ด $receive_invoice_description: | คุณสามารถระบุจำนวนและเหตุผลของการชำระเงินได้ จะทำให้ผู้ส่งเงินสามารถเข้าใจและชำระเงินได้เร็วขึ้น. @@ -217,7 +215,7 @@ Appearance: คุณลักษณะ Light: ไลค์ Dark: ดาร์ค System: ระบบ -$stake_asset: สเต็ก %symbol% +$stake_asset: วางเดิมพัน %symbol% Earn from your tokens while holding them: รับโทเคนของคุณที่ได้จากการที่ถือมันไว้ Why this is safe: ทำไมสิ่งนี้ถึงปลอดถัย Why is staking safe?: เหตุใดการสเต็กจึงปลอดภัย? @@ -463,9 +461,8 @@ A request is already pending: คำขอเคยส่งไปแล้ว Please confirm operation using biometrics: โปรดยืนยันการทำรายการด้วย Biometrics Scan QR Code: สแกน QR Code Permission denied. Please grant camera permission to use the QR code scanner.: ไม่ได้รับการอนุญาต. โปรดอนุญาตให้แอปใช้กล้องเพื่อใช้งานในการสแกน QR Code. -Unsupported QR code format: รูปแบบ QR Code ไม่ได้รองรับ +This QR Code is not supported: QR Code นี้ไม่รองรับ An error on the server side. Please try again.: เกิดข้อผิดพลาดบนเซิร์ฟเวอร์. โปรดลองใหม่อีกครั้ง. -Unrecognized QR Code: ไม่รู้จัก QR Code นี้ Address was saved!: ที่อยู่บัญชีถูกบันทึกแล้ว! US Dollar: ยูเอส ดอลลาร์ Euro: ยูโร diff --git a/src/i18n/tr.yaml b/src/i18n/tr.yaml index a61453b4..815eadbc 100644 --- a/src/i18n/tr.yaml +++ b/src/i18n/tr.yaml @@ -112,9 +112,7 @@ $logout_without_backup_warning: Bu cüzdanı yedeklemediniz. Cüzdanı kaldırı $logout_accounts_without_backup_warning: "%links% için yedekleme yapmadınız. Devam ederseniz tokenlarınıza ve NFT'lerinize erişiminizi kaybedersiniz." $logout_warning: Bu, mevcut cüzdanı uygulamadan kaldıracaktır. **Gizli kelimelerinizi** yedeklediğinizden emin olun. Your address was copied!: Adresiniz kopyalandı! -Show QR Code: QR Kodunu Göster Create Deposit Link: Para Yatırma Bağlantısı Oluşturun -QR Code: QR Kodu $receive_invoice_description: | Göndericiye zaman kazandırmak adına ödeme tutarını ve amacını belirtebilirsiniz. @@ -276,15 +274,15 @@ Connect: Bağlan Dapp Permissions: Dapp İzinleri $dapp_can_view_balance: "%dappname% cüzdan adresini ve bakiyesini görüntüleyebilecek." Send Transaction: İşlemi gönder -Payload: Payload +Payload: Yük $many_transactions: "%1$d işlem" Total Amount: Toplam tutar Unstaking: Unstaking gerçekleşiyor -Handle ton:// links: ton uzantılı isimler:// link'ler +Handle ton:// links: ton:// bağlantılarını işle Back up wallet to have full access to it: Tam erişime sahip olmak için cüzdanı yedekleyin Consider More Secure Version: Daha güvenli sürümü kullanmayı düşünün Install our native app or browser extension.: Yerel uygulamamızı veya tarayıcı uzantımızı yükleyin. -Scam: Scam +Scam: Sahtekarlık Scam comment is hidden.: Scam yorumu gizlendi. Display: Görüntüle $dapp_transfer_tokens_payload: Hesabınızdan %adres%'e %amount% gönderiliyor @@ -353,7 +351,7 @@ Included: Dahil edildi Price Impact: Fiyat etkisi Minimum Received: Minimum alınan Slippage: Kayma toleransı -$swap_from_to: "%from% %icon% %to% arasında takas yapın" +$swap_from_to: "%from% %icon% %to% değiştirin" The exchange rate is below market value!: Takas oranı piyasa değerinin %value% altındadır! Invalid Pair: Geçersiz işlem çifti We do not recommend to perform an exchange, try to specify a lower amount.: Takas yapmanızı tavsiye etmiyoruz, daha düşük bir tutar belirtmeye çalışın. @@ -462,9 +460,8 @@ A request is already pending: Bir istek zaten beklemede Please confirm operation using biometrics: Lütfen biyometri kullanarak işlemi onaylayın Scan QR Code: QR kodunu tarayın Permission denied. Please grant camera permission to use the QR code scanner.: İzin reddedildi. Lütfen kameraya QR kod tarayıcısını kullanma izni verin. -Unsupported QR code format: Desteklenmeyen QR kod formatı +This QR Code is not supported: Bu QR kodu desteklenmiyor An error on the server side. Please try again.: Sunucu tarafında bir hata oluştu. Lütfen tekrar deneyin. -Unrecognized QR Code: Tanınmayan QR Kodu Address was saved!: Adres kaydedildi! US Dollar: ABD doları Euro: Euro diff --git a/src/i18n/uk.yaml b/src/i18n/uk.yaml index 8b929060..c3ffca44 100644 --- a/src/i18n/uk.yaml +++ b/src/i18n/uk.yaml @@ -115,9 +115,7 @@ $logout_accounts_without_backup_warning: Резервну копію не бул $logout_warning: Це видалить поточний гаманець з програми. Переконайтеся, що ви зробили резервну копію своїх **секретних слів**. $logout_confirm: Вийти зі **всіх** гаманців Your address was copied!: Адресу скопійовано! -Show QR Code: Показати QR-код Create Deposit Link: Посилання на депозит -QR Code: QR-код $receive_invoice_description: | Вкажіть суму та призначення платежу, якщо це необхідно. @@ -219,7 +217,7 @@ Appearance: Зовнішній вигляд Light: Світла Dark: Темна System: Системна -$stake_asset: Стейкінг %symbol% +$stake_asset: Застейкати %symbol% Earn from your tokens while holding them: Отримуйте пасивний дохід від зберігання %symbol% на надійному офіційному смарт-контракті Why this is safe: Чому це безпечно Why is staking safe?: Це точно безпечно? @@ -469,9 +467,8 @@ A request is already pending: Підключення біометрії вже Please confirm operation using biometrics: Будь ласка, підтвердіть операцію за допомогою біометрії Scan QR Code: Сканувати QR-код Permission denied. Please grant camera permission to use the QR code scanner.: Доступ заборонено. Будь ласка, дайте камері дозвіл на використання сканера QR-коду. -Unsupported QR code format: Непідтримуваний формат QR-коду +This QR Code is not supported: Цей QR-код не підтримується An error on the server side. Please try again.: Помилка на стороні сервера. Будь ласка, спробуйте ще раз. -Unrecognized QR Code: Нерозпізнаний QR-код Address was saved!: Адреса збережена! US Dollar: Долар США Euro: Євро @@ -644,7 +641,7 @@ Codes don’t match: Коди не співпадають Enter your new code: Введіть новий код Re-enter your new code: Введіть новий код ще раз MyTonWallet Features: Функції MyTonWallet -Install on Desktop: Встановити на робочий стіл +Install on Desktop: Встановити на комп’ютер Install on Mobile: Встановити на мобільний пристрій Install App: Встановити додаток WrongNetwork: Dapp запитав транзакцію з іншої мережі. diff --git a/src/i18n/zh-Hans.yaml b/src/i18n/zh-Hans.yaml index 7035c12a..39ed8b61 100644 --- a/src/i18n/zh-Hans.yaml +++ b/src/i18n/zh-Hans.yaml @@ -110,9 +110,7 @@ $logout_without_backup_warning: 您尚未备份钱包!如果您注销,您将 $logout_accounts_without_backup_warning: 您还没有备份此账户 %links%。 如果您注销,您将永远丧失您的代币和 NFT ! $logout_warning: 这将从应用中移除当前钱包。请确保已备份您的**密语**。 Your address was copied!: 您的地址已复制! -Show QR Code: 显示二维码 Create Deposit Link: 创建存款链接 -QR Code: 二维码 $receive_invoice_description: 您可以指定付款金额和收款地址来节省付款方的一些时间。 Amount: 数量 Comment: 留言 @@ -454,9 +452,8 @@ A request is already pending: 请求已待处理 Please confirm operation using biometrics: 请使用生物识别确认操作 Scan QR Code: 扫描二维码 Permission denied. Please grant camera permission to use the QR code scanner.: 没有权限。 请授予相机使用 QR 扫描仪的权限。 -Unsupported QR code format: 不支持的QR码格式 +This QR Code is not supported: 此二维码不受支持 An error on the server side. Please try again.: 服务器端的错误。请再试一次。 -Unrecognized QR Code: 无法识别的二维码 Address was saved!: 地址已保存! US Dollar: 美元 Euro: 欧元 @@ -628,7 +625,7 @@ Codes don’t match: 代码不匹配 Enter your new code: 输入您的新代码 Re-enter your new code: 再次输入您的新代码 MyTonWallet Features: MyTonWallet 功能 -Install on Desktop: 安装在桌面 +Install on Desktop: 安装到桌面 Install on Mobile: 安装在手机 Install App: 安装应用 WrongNetwork: Dapp 从另一个网络请求交易。 diff --git a/src/i18n/zh-Hant.yaml b/src/i18n/zh-Hant.yaml index 621999d9..115b954f 100644 --- a/src/i18n/zh-Hant.yaml +++ b/src/i18n/zh-Hant.yaml @@ -110,9 +110,7 @@ $logout_without_backup_warning: 您尚未備份錢包。 如果您註銷,您 $logout_accounts_without_backup_warning: 您還沒有備份 %links%。 如果您註銷,您將喪失您的代幣和 NFT。 $logout_warning: 這將從應用中移除當前錢包。請確保已備份您的**密語**。 Your address was copied!: 您的地址已被複製! -Show QR Code: 顯示 QR Code Create Deposit Link: 建立存款連結 -QR Code: QR Code $receive_invoice_description: 您可以指定付款金額和地址來節省發送人的一些時間。 Amount: 數量 Comment: 註記欄 @@ -454,9 +452,8 @@ A request is already pending: 請求已待處理 Please confirm operation using biometrics: 請使用生物辨識確認操作 Scan QR Code: 掃描二維碼 Permission denied. Please grant camera permission to use the QR code scanner.: 沒有權限。 請授予相機使用 QR 掃描器的權限。 -Unsupported QR code format: 不支持的QR碼格式 +This QR Code is not supported: 此二維碼不受支援 An error on the server side. Please try again.: 服務器端的錯誤。請再試一次。 -Unrecognized QR Code: 無法辨識的二維碼 Address was saved!: 地址已儲存! US Dollar: 美金 Euro: 歐元 @@ -628,7 +625,7 @@ Codes don’t match: 代碼不匹配 Enter your new code: 輸入您的新代碼 Re-enter your new code: 再次輸入您的新代碼 MyTonWallet Features: MyTonWallet 功能 -Install on Desktop: 安裝在桌面 +Install on Desktop: 安裝到桌面 Install on Mobile: 安裝在手機 Install App: 安裝應用程式 WrongNetwork: Dapp 要求來自另一個網路的交易。 diff --git a/src/lib/teact/teactn.tsx b/src/lib/teact/teactn.tsx index 0ebfdb1c..3d32e9c9 100644 --- a/src/lib/teact/teactn.tsx +++ b/src/lib/teact/teactn.tsx @@ -351,6 +351,7 @@ export function typify< if (DEBUG) { (window as any).getGlobal = getGlobal; (window as any).setGlobal = setGlobal; + (window as any).getActions = getActions; document.addEventListener('dblclick', () => { // eslint-disable-next-line no-console diff --git a/src/util/capacitor/index.ts b/src/util/capacitor/index.ts index 1d0f0d45..e24bb600 100644 --- a/src/util/capacitor/index.ts +++ b/src/util/capacitor/index.ts @@ -79,7 +79,7 @@ export async function initCapacitor() { } App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => { - processDeeplink(event.url); + void processDeeplink(event.url); }); App.addListener('backButton', ({ canGoBack }) => { diff --git a/src/util/decimals.ts b/src/util/decimals.ts index 6eb74d65..95389f19 100644 --- a/src/util/decimals.ts +++ b/src/util/decimals.ts @@ -22,7 +22,3 @@ export function toBig(value: bigint | number, decimals: number = TONCOIN.decimal export function roundDecimal(value: string, decimals: number) { return Big(value).round(decimals).toString(); } - -export function getIsPositiveDecimal(value: string) { - return !value.startsWith('-'); -} diff --git a/src/util/deeplink/index.ts b/src/util/deeplink/index.ts index c13a5312..d725c12a 100644 --- a/src/util/deeplink/index.ts +++ b/src/util/deeplink/index.ts @@ -1,7 +1,7 @@ import { getActions, getGlobal } from '../../global'; import type { ActionPayloads } from '../../global/types'; -import { ActiveTab } from '../../global/types'; +import { ActiveTab, ContentTab } from '../../global/types'; import { DEFAULT_CEX_SWAP_SECOND_TOKEN_SLUG, @@ -22,6 +22,7 @@ import { logDebug, logDebugError } from '../logs'; import { openUrl } from '../openUrl'; import { waitRender } from '../renderPromise'; import { tonConnectGetDeviceInfo } from '../tonConnectEnvironment'; +import { isTelegramUrl } from '../url'; import { CHECKIN_URL, SELF_PROTOCOL, @@ -42,6 +43,7 @@ enum DeeplinkCommand { Stake = 'stake', Giveaway = 'giveaway', Transfer = 'transfer', + Explore = 'explore', } let urlAfterSignIn: string | undefined; @@ -49,30 +51,31 @@ let urlAfterSignIn: string | undefined; export function processDeeplinkAfterSignIn() { if (!urlAfterSignIn) return; - processDeeplink(urlAfterSignIn); + void processDeeplink(urlAfterSignIn); urlAfterSignIn = undefined; } export function openDeeplinkOrUrl(url: string, isExternal = false, isFromInAppBrowser = false) { if (isTonDeeplink(url) || isTonConnectDeeplink(url) || isSelfDeeplink(url)) { - processDeeplink(url, isFromInAppBrowser); + void processDeeplink(url, isFromInAppBrowser); } else { void openUrl(url, isExternal); } } -export function processDeeplink(url: string, isFromInAppBrowser = false) { +// Returns `true` if the link has been processed, ideally resulting to a UI action +export function processDeeplink(url: string, isFromInAppBrowser = false): Promise { if (!getGlobal().currentAccountId) { urlAfterSignIn = url; } if (isTonConnectDeeplink(url)) { - void processTonConnectDeeplink(url, isFromInAppBrowser); + return processTonConnectDeeplink(url, isFromInAppBrowser); } else if (isSelfDeeplink(url)) { - processSelfDeeplink(url); + return processSelfDeeplink(url); } else { - void processTonDeeplink(url); + return processTonDeeplink(url); } } @@ -80,16 +83,17 @@ export function isTonDeeplink(url: string) { return url.startsWith(TON_PROTOCOL); } -async function processTonDeeplink(url: string) { +// Returns `true` if the link has been processed, ideally resulting to a UI action +async function processTonDeeplink(url: string): Promise { const params = parseTonDeeplink(url); - if (!params) return; + if (!params) return false; await waitRender(); const actions = getActions(); const global = getGlobal(); if (!global.currentAccountId) { - return; + return false; } const { @@ -123,7 +127,7 @@ async function processTonDeeplink(url: string) { actions.showError({ error: '$unknown_token_address', }); - return; + return true; } const accountToken = selectAccountTokenBySlug(global, globalToken.slug); @@ -131,7 +135,7 @@ async function processTonDeeplink(url: string) { actions.showError({ error: '$dont_have_required_token', }); - return; + return true; } startTransferParams.tokenSlug = globalToken.slug; @@ -144,7 +148,7 @@ async function processTonDeeplink(url: string) { actions.showError({ error: '$dont_have_required_nft', }); - return; + return true; } startTransferParams.nfts = [accountNft]; @@ -155,6 +159,8 @@ async function processTonDeeplink(url: string) { if (getIsLandscape()) { actions.setLandscapeActionsActiveTabIndex({ index: ActiveTab.Transfer }); } + + return true; } export function parseTonDeeplink(value?: string) { @@ -195,9 +201,10 @@ function isTonConnectDeeplink(url: string) { || omitProtocol(url).startsWith(omitProtocol(TONCONNECT_UNIVERSAL_URL)); } -async function processTonConnectDeeplink(url: string, isFromInAppBrowser = false) { +// Returns `true` if the link has been processed, ideally resulting to a UI action +async function processTonConnectDeeplink(url: string, isFromInAppBrowser = false): Promise { if (!isTonConnectDeeplink(url)) { - return; + return false; } const { openLoadingOverlay, closeLoadingOverlay } = getActions(); @@ -216,20 +223,21 @@ async function processTonConnectDeeplink(url: string, isFromInAppBrowser = false if (returnUrl) { openUrl(returnUrl, !isFromInAppBrowser); } + + return true; } export function isSelfDeeplink(url: string) { url = forceHttpsProtocol(url); return url.startsWith(SELF_PROTOCOL) - || SELF_UNIVERSAL_URLS.some((u) => omitProtocol(url).startsWith(omitProtocol(u))); + || SELF_UNIVERSAL_URLS.some((u) => url.startsWith(u)); } -export function processSelfDeeplink(deeplink: string) { +// Returns `true` if the link has been processed, ideally resulting to a UI action +export async function processSelfDeeplink(deeplink: string): Promise { try { - if (deeplink.startsWith(SELF_PROTOCOL)) { - deeplink = deeplink.replace(SELF_PROTOCOL, `${SELF_UNIVERSAL_URLS[0]}/`); - } + deeplink = convertSelfDeeplinkToSelfUrl(deeplink); const { pathname, searchParams } = new URL(deeplink); const command = pathname.split('/').find(Boolean); @@ -244,15 +252,15 @@ export function processSelfDeeplink(deeplink: string) { case DeeplinkCommand.CheckinWithR: { const r = pathname.match(/r\/(.*)$/)?.[1]; const url = `${CHECKIN_URL}${r ? `?r=${r}` : ''}`; - openUrl(url); - break; + void openUrl(url); + return true; } case DeeplinkCommand.Giveaway: { const giveawayId = pathname.match(/giveaway\/([^/]+)/)?.[1]; const url = `${GIVEAWAY_CHECKIN_URL}${giveawayId ? `?giveawayId=${giveawayId}` : ''}`; - openUrl(url); - break; + void openUrl(url); + return true; } case DeeplinkCommand.Swap: { @@ -267,7 +275,7 @@ export function processSelfDeeplink(deeplink: string) { amountIn: toNumberOrEmptyString(searchParams.get('amount')) || '10', }); } - break; + return true; } case DeeplinkCommand.BuyWithCrypto: { @@ -282,7 +290,7 @@ export function processSelfDeeplink(deeplink: string) { amountIn: toNumberOrEmptyString(searchParams.get('amount')) || '100', }); } - break; + return true; } case DeeplinkCommand.BuyWithCard: { @@ -291,7 +299,7 @@ export function processSelfDeeplink(deeplink: string) { } else { actions.openOnRampWidgetModal({ chain: 'ton' }); } - break; + return true; } case DeeplinkCommand.Stake: { @@ -300,24 +308,81 @@ export function processSelfDeeplink(deeplink: string) { } else { actions.startStaking(); } - break; + return true; } case DeeplinkCommand.Transfer: { - let tonDeeplink = forceHttpsProtocol(deeplink); - SELF_UNIVERSAL_URLS.forEach((prefix) => { - if (tonDeeplink.startsWith(prefix)) { - tonDeeplink = tonDeeplink.replace(`${prefix}/`, TON_PROTOCOL); + return await processTonDeeplink(convertSelfUrlToTonDeeplink(deeplink)); + } + + case DeeplinkCommand.Explore: { + actions.closeSettings(); + actions.openExplore(); + actions.setActiveContentTab({ tab: ContentTab.Explore }); + + const host = pathname.split('/').filter(Boolean)[1]; + if (host) { + const matchingSite = getGlobal().exploreData?.sites.find(({ url }) => { + const siteHost = isTelegramUrl(url) + ? new URL(url).pathname.split('/').filter(Boolean)[0] + : new URL(url).hostname; + + return siteHost === host; + }); + + if (matchingSite) { + void openUrl(matchingSite.url); } - }); + } - processTonDeeplink(tonDeeplink); - break; + return true; } } } catch (err) { logDebugError('processSelfDeeplink', err); } + + return false; +} + +// Parses only transfer params from the deeplink. Returns `undefined` if it's not a transfer deeplink. +export function parseDeeplinkTransferParams(url: string) { + let tonDeeplink = url; + + if (isSelfDeeplink(url)) { + try { + url = convertSelfDeeplinkToSelfUrl(url); + const { pathname } = new URL(url); + const command = pathname.split('/').find(Boolean); + + if (command === DeeplinkCommand.Transfer) { + tonDeeplink = convertSelfUrlToTonDeeplink(url); + } + } catch (err) { + logDebugError('parseDeeplinkTransferParams', err); + } + } + + return parseTonDeeplink(tonDeeplink); +} + +function convertSelfDeeplinkToSelfUrl(deeplink: string) { + if (deeplink.startsWith(SELF_PROTOCOL)) { + return deeplink.replace(SELF_PROTOCOL, `${SELF_UNIVERSAL_URLS[0]}/`); + } + return deeplink; +} + +function convertSelfUrlToTonDeeplink(deeplink: string) { + deeplink = forceHttpsProtocol(deeplink); + + for (const selfUniversalUrl of SELF_UNIVERSAL_URLS) { + if (deeplink.startsWith(selfUniversalUrl)) { + return deeplink.replace(`${selfUniversalUrl}/`, TON_PROTOCOL); + } + } + + return deeplink; } function omitProtocol(url: string) { diff --git a/src/util/electron.ts b/src/util/electron.ts index 115486b0..ab3aabe4 100644 --- a/src/util/electron.ts +++ b/src/util/electron.ts @@ -4,6 +4,6 @@ import { processDeeplink } from './deeplink'; export function initElectron() { window.electron?.on(ElectronEvent.DEEPLINK, ({ url }: { url: string }) => { - processDeeplink(url); + void processDeeplink(url); }); } diff --git a/src/util/fee/swapFee.ts b/src/util/fee/swapFee.ts index 104c1fc4..519fa24f 100644 --- a/src/util/fee/swapFee.ts +++ b/src/util/fee/swapFee.ts @@ -12,8 +12,9 @@ import { fromDecimal, toBig } from '../decimals'; import { getChainBySlug, getIsNativeToken } from '../tokens'; type ExplainSwapFeeInput = Pick & { + swapType: SwapType; /** The balance of the "in" token blockchain's native token. Undefined means that it's unknown. */ nativeTokenInBalance: bigint | undefined; }; @@ -47,7 +48,8 @@ export type ExplainedSwapFee = { shouldShowOurFee: boolean; }; -type MaxSwapAmountInput = Pick & { +type MaxSwapAmountInput = Pick & { + swapType: SwapType; /** The balance of the "in" token. Undefined means that it's unknown. */ tokenInBalance: bigint | undefined; tokenIn: Pick | undefined; diff --git a/src/util/isValidAddressOrDomain.ts b/src/util/isValidAddressOrDomain.ts index 5993efeb..0ccb0970 100644 --- a/src/util/isValidAddressOrDomain.ts +++ b/src/util/isValidAddressOrDomain.ts @@ -3,7 +3,10 @@ import type { ApiChain } from '../api/types'; import { getChainConfig } from './chain'; import dns from './dns'; -export function isValidAddressOrDomain(address: string, chain: ApiChain) { +export function isValidAddressOrDomain(address: string, chain: ApiChain, allowPrefix?: boolean) { const config = getChainConfig(chain); - return address && (config.addressRegex.test(address) || (config.isDnsSupported && dns.isDnsDomain(address))); + return address && ( + config[allowPrefix ? 'addressPrefixRegex' : 'addressRegex'].test(address) + || (config.isDnsSupported && dns.isDnsDomain(address)) + ); }