diff --git a/VERSION_CODE b/VERSION_CODE index 05cf25896..252b382b3 100644 --- a/VERSION_CODE +++ b/VERSION_CODE @@ -1 +1 @@ -201 \ No newline at end of file +202 \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 9b98a0389..fcf0d176f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -5,7 +5,7 @@ buildscript { buildToolsVersion = "33.0.0" minSdkVersion = 24 compileSdkVersion = 34 - targetSdkVersion = 33 + targetSdkVersion = 34 kotlinVersion = "1.8.10" ndkVersion = "24.0.8215888" } diff --git a/app/Navigation.tsx b/app/Navigation.tsx index 12b8d3df5..1cbff4afb 100644 --- a/app/Navigation.tsx +++ b/app/Navigation.tsx @@ -78,7 +78,7 @@ import { LedgerDeviceSelectionFragment } from './fragments/ledger/LedgerDeviceSe import { LedgerSelectAccountFragment } from './fragments/ledger/LedgerSelectAccountFragment'; import { LedgerAppFragment } from './fragments/ledger/LedgerAppFragment'; import { LedgerSignTransferFragment } from './fragments/ledger/LedgerSignTransferFragment'; -import { AppStartAuthFragment } from './fragments/AppStartAuthFragment'; +import { AppAuthFragment } from './fragments/AppAuthFragment'; import { BackupIntroFragment } from './fragments/onboarding/BackupIntroFragment'; import { ProductsFragment } from './fragments/wallet/ProductsFragment'; import { PendingTxPreviewFragment } from './fragments/wallet/PendingTxPreviewFragment'; @@ -94,6 +94,9 @@ import { SearchEngineFragment } from './fragments/SearchEngineFragment'; import { ProductsListFragment } from './fragments/wallet/ProductsListFragment'; import { SortedHintsWatcher } from './components/SortedHintsWatcher'; import { PendingTxsWatcher } from './components/PendingTxsWatcher'; +import { TonconnectWatcher } from './components/TonconnectWatcher'; +import { SessionWatcher } from './components/SessionWatcher'; +import { MandatoryAuthSetupFragment } from './fragments/secure/MandatoryAuthSetupFragment'; const Stack = createNativeStackNavigator(); Stack.Navigator.displayName = 'MainStack'; @@ -178,6 +181,30 @@ function lockedModalScreen(name: string, component: React.ComponentType, sa ); } +function fullScreenModal(name: string, component: React.ComponentType, safeArea: EdgeInsets) { + const theme = useTheme(); + return ( + + ); +} + function transparentModalScreen(name: string, component: React.ComponentType, safeArea: EdgeInsets) { const theme = useTheme(); return ( @@ -293,6 +320,7 @@ const navigation = (safeArea: EdgeInsets) => [ modalScreen('NewAddressFormat', NewAddressFormatFragment, safeArea), modalScreen('BounceableFormatAbout', BounceableFormatAboutFragment, safeArea), modalScreen('SearchEngine', SearchEngineFragment, safeArea), + lockedModalScreen('MandatoryAuthSetup', MandatoryAuthSetupFragment, safeArea), // Holders genericScreen('HoldersLanding', HoldersLandingFragment, safeArea, true, 0), @@ -305,7 +333,8 @@ const navigation = (safeArea: EdgeInsets) => [ transparentModalScreen('Alert', AlertFragment, safeArea), transparentModalScreen('ScreenCapture', ScreenCaptureFragment, safeArea), transparentModalScreen('AccountSelector', AccountSelectorFragment, safeArea), - fullScreen('AppStartAuth', AppStartAuthFragment), + fullScreen('AppStartAuth', AppAuthFragment), + fullScreenModal('AppAuth', AppAuthFragment, safeArea), genericScreen('DAppWebView', DAppWebViewFragment, safeArea, true, 0), genericScreen('DAppWebViewLocked', DAppWebViewFragment, safeArea, true, 0, { gestureEnabled: false }), ]; @@ -409,9 +438,6 @@ export const Navigation = memo(() => { // Watch blocks useBlocksWatcher(); - // Watch for TonConnect requests - useTonconnectWatcher(); - // Watch for holders updates useHoldersWatcher(); @@ -441,6 +467,8 @@ export const Navigation = memo(() => { + + ); diff --git a/app/Root.tsx b/app/Root.tsx index e60208c2e..270eb6aec 100644 --- a/app/Root.tsx +++ b/app/Root.tsx @@ -15,8 +15,12 @@ import { LogBox } from 'react-native'; import { AddressBookLoader } from './engine/AddressBookContext'; import { ThemeProvider } from './engine/ThemeContext'; import { PriceLoader } from './engine/PriceContext'; +import { migrateDontShowComments } from './engine/state/spam'; +import { AppBlurContextProvider } from './components/AppBlurContext'; const PERSISTANCE_VERSION = '23'; +// set default value for spam comments +migrateDontShowComments(); LogBox.ignoreAllLogs() @@ -55,7 +59,9 @@ export const Root = memo(() => { - + + + diff --git a/app/analytics/mixpanel.ts b/app/analytics/mixpanel.ts index 47cc899d0..c4726c19e 100644 --- a/app/analytics/mixpanel.ts +++ b/app/analytics/mixpanel.ts @@ -7,8 +7,6 @@ import { IS_SANDBOX } from '../engine/state/network'; export enum MixpanelEvent { Reset = 'reset', Screen = 'screen', - LinkReceived = 'link_received', - NotificationReceived = 'notification_received', AppOpen = 'app_open', AppClose = 'app_close', Holders = 'holders', @@ -17,14 +15,10 @@ export enum MixpanelEvent { HoldersInfoClose = 'holders_info_close', HoldersEnrollmentClose = 'holders_entrollment_close', HoldersClose = 'holders_close', - AppInstall = 'app_install', - AppInstallCancel = 'app_install_cancel', - AppUninstall = 'app_uninstall', Connect = 'connect', Transfer = 'transfer', TransferCancel = 'transfer_cancel', ProductBannerClick = 'product_banner_click', - BrowserBannerShown = 'browser_banner_shown', BrowserSearch = 'browser_search', } diff --git a/app/components/AppBlurContext.tsx b/app/components/AppBlurContext.tsx new file mode 100644 index 000000000..2b09b15e4 --- /dev/null +++ b/app/components/AppBlurContext.tsx @@ -0,0 +1,58 @@ +import { BlurView } from "expo-blur"; +import { createContext, useContext, useRef, useState } from "react"; +import { Platform } from "react-native"; +import Animated, { Easing, FadeOut } from "react-native-reanimated"; + +export const AppBlurContext = createContext<{ + blur: boolean, + setBlur: (newState: boolean) => void, + getBlur: () => boolean + setAuthInProgress: (newState: boolean) => void +} | null>(null); + +export const AppBlurContextProvider = ({ children }: { children: any }) => { + const [blur, setBlurState] = useState(false); + const blurRef = useRef(blur); + const getBlur = () => blurRef.current; + + const authInProgressRef = useRef(false); + const setAuthInProgress = (newState: boolean) => authInProgressRef.current = newState; + + const setBlur = (newState: boolean) => { + // On iOS we don't want to show blur when auth is in progress (biometrics prompt is shown AppState is 'inactive') + if (newState && authInProgressRef.current && Platform.OS === 'ios') { + return; + } + blurRef.current = newState; + setBlurState(newState); + } + + return ( + + {children} + {blur + ? ( + + + + ) + : null + } + + ); +}; + +export function useAppBlur() { + let res = useContext(AppBlurContext); + if (!res) { + throw Error('No AppBlur found'); + } + return res; +} \ No newline at end of file diff --git a/app/components/CheckBox.tsx b/app/components/CheckBox.tsx index 7ecca3608..645f6807a 100644 --- a/app/components/CheckBox.tsx +++ b/app/components/CheckBox.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { Pressable, View, Text, StyleProp, ViewStyle } from "react-native"; import CheckMark from '../../assets/ic_check_mark.svg'; import { useTheme } from '../engine/hooks'; +import { Typography } from "./styles"; export const CheckBox = React.memo(( { @@ -53,12 +54,10 @@ export const CheckBox = React.memo(( }}> {isChecked && } - + }, Typography.regular15_20]}> {text} diff --git a/app/components/ConnectedAppButton.tsx b/app/components/ConnectedAppButton.tsx index c228ae80e..2ec537881 100644 --- a/app/components/ConnectedAppButton.tsx +++ b/app/components/ConnectedAppButton.tsx @@ -62,7 +62,7 @@ export const ConnectedAppButton = memo(({ padding: 10 }}> onFocus(0)} + cursorColor={theme.accent} /> ) diff --git a/app/components/Item.tsx b/app/components/Item.tsx index 2aff09070..43c0a2ad7 100644 --- a/app/components/Item.tsx +++ b/app/components/Item.tsx @@ -3,6 +3,7 @@ import { ImageSourcePropType, Pressable, Text, View, Image, Platform, StyleProp, import { Switch } from 'react-native-gesture-handler'; import { useTheme } from '../engine/hooks'; import { memo } from 'react'; +import { Typography } from './styles'; export const Item = memo((props: { title?: string, hint?: string, onPress?: () => void, backgroundColor?: string, textColor?: string }) => { const theme = useTheme(); @@ -51,13 +52,16 @@ export const ItemSwitch = memo((props: { onPress={() => { props.onChange(!props.value); }} - style={{ - flexGrow: 1, - alignItems: 'center', justifyContent: 'space-between', - flexDirection: 'row', - padding: 20, - minHeight: 72 - }} + style={[ + { + flexGrow: 1, + alignItems: 'center', justifyContent: 'space-between', + flexDirection: 'row', + padding: 20, + minHeight: 72 + }, + Platform.select({ android: { opacity: props.disabled ? 0.8 : 1 } }), + ]} disabled={props.disabled} > @@ -69,12 +73,8 @@ export const ItemSwitch = memo((props: { )} diff --git a/app/components/QRCode/QRCode.tsx b/app/components/QRCode/QRCode.tsx index 26b86b08a..27dad9784 100644 --- a/app/components/QRCode/QRCode.tsx +++ b/app/components/QRCode/QRCode.tsx @@ -220,7 +220,7 @@ export const QRCode = memo((props: { src={props.icon?.preview256} blurhash={props.icon?.blurhash} width={46} - heigh={46} + height={46} borderRadius={23} lockLoading />} diff --git a/app/components/SessionWatcher.tsx b/app/components/SessionWatcher.tsx new file mode 100644 index 000000000..7d5413a04 --- /dev/null +++ b/app/components/SessionWatcher.tsx @@ -0,0 +1,61 @@ +import { NavigationContainerRefWithCurrent } from "@react-navigation/native"; +import { useEffect, useRef } from "react"; +import { AppState, Platform } from "react-native"; +import { useLockAppWithAuthState } from "../engine/hooks/settings"; +import { getLastAuthTimestamp } from "./secure/AuthWalletKeys"; +import { useAppBlur } from "./AppBlurContext"; + +const appLockTimeout = 1000 * 60 * 15; // 15 minutes + +export const SessionWatcher = (({ navRef }: { navRef: NavigationContainerRefWithCurrent }) => { + const [locked,] = useLockAppWithAuthState(); + const lastStateRef = useRef(null); + const { setBlur } = useAppBlur(); + useEffect(() => { + if (!locked) { + setBlur(false); + return; + } + + const checkAndNavigate = () => { + const lastAuthAt = getLastAuthTimestamp() ?? 0; + + if (lastAuthAt + appLockTimeout < Date.now()) { + navRef.navigate('AppAuth'); + } else { + setBlur(false); + } + } + + const subscription = AppState.addEventListener('change', (newState) => { + + if (Platform.OS === 'ios') { // ios goes to inactive on biometric auth + if (newState === 'background') { + setBlur(true); + } else if (newState === 'inactive') { + setBlur(true); + } else if (newState === 'active' && lastStateRef.current === 'background') { + checkAndNavigate(); + } else { + setBlur(false); + } + } else { + if (newState === 'background') { + setBlur(true); + } else if (newState === 'active' && lastStateRef.current === 'background') { + checkAndNavigate(); + } else { + setBlur(false); + } + } + + // update last state + lastStateRef.current = newState; + }); + + return () => { + subscription.remove(); + }; + }, [locked]); + return null; +}); \ No newline at end of file diff --git a/app/components/TonconnectWatcher.tsx b/app/components/TonconnectWatcher.tsx new file mode 100644 index 000000000..00505b586 --- /dev/null +++ b/app/components/TonconnectWatcher.tsx @@ -0,0 +1,7 @@ +import { useTonconnectWatcher } from "../engine/tonconnectWatcher"; + +export const TonconnectWatcher = () => { + // Watch for TonConnect requests + useTonconnectWatcher(); + return null; +} \ No newline at end of file diff --git a/app/components/ValueComponent.tsx b/app/components/ValueComponent.tsx index 28f0f3922..b674919fb 100644 --- a/app/components/ValueComponent.tsx +++ b/app/components/ValueComponent.tsx @@ -61,6 +61,7 @@ export function ValueComponent(props: { decimals?: number | null, suffix?: string, prefix?: string, + forcePrecision?: boolean, }) { let t: string; const { decimalSeparator } = getNumberFormatSettings(); @@ -107,7 +108,7 @@ export function ValueComponent(props: { } // Determine the precision of the value - const precision = !!props.decimals + const precision = (!!props.decimals && !props.forcePrecision) ? (r.length >= 1) && real !== 0 ? 2 : props.decimals : props.precision ? props.precision diff --git a/app/components/WImage.tsx b/app/components/WImage.tsx index f96c6f93f..8c962202b 100644 --- a/app/components/WImage.tsx +++ b/app/components/WImage.tsx @@ -9,7 +9,7 @@ export const WImage = memo((props: { src?: string | null | undefined, requireSource?: ImageRequireSource, blurhash?: string | null | undefined, - heigh: number, + height: number, width: number, borderRadius: number, style?: StyleProp, @@ -22,16 +22,21 @@ export const WImage = memo((props: { if (url && blurhash) { return ( ); @@ -40,16 +45,21 @@ export const WImage = memo((props: { if (url) { return ( ); @@ -58,15 +68,20 @@ export const WImage = memo((props: { if (props.requireSource) { return ( ); @@ -74,14 +89,14 @@ export const WImage = memo((props: { return ( diff --git a/app/components/animated/AnimatedChildrenCollapsible.tsx b/app/components/animated/AnimatedChildrenCollapsible.tsx index 72c153976..cbfb39298 100644 --- a/app/components/animated/AnimatedChildrenCollapsible.tsx +++ b/app/components/animated/AnimatedChildrenCollapsible.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ReactNode, memo, useEffect, useState } from "react" +import { ReactNode, memo, useEffect } from "react" import { Pressable, View, ViewStyle, Text } from "react-native"; import Animated, { Easing, Extrapolation, FadeInUp, FadeOutUp, interpolate, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; import { StyleProp } from "react-native"; @@ -9,41 +9,46 @@ import { t } from "../../i18n/t"; import { Typography } from "../styles"; import { useTypedNavigation } from "../../utils/useTypedNavigation"; -export const AnimatedChildrenCollapsible = memo(({ +type Item = T & { height?: number }; + +type AnimatedChildrenCollapsibleProps = { + collapsed: boolean, + items: Item[], + renderItem: (item: Item, index: number) => any, + itemHeight?: number, + showDivider?: boolean, + dividerStyle?: StyleProp, + divider?: any, + additionalFirstItem?: ReactNode, + style?: StyleProp, + limitConfig?: CollapsibleCardsLimitConfig, +}; + +const AnimatedChildrenCollapsibleComponent = ({ collapsed, items, renderItem, - itemHeight = 82, + itemHeight = 86, showDivider = true, dividerStyle, divider, additionalFirstItem, style, limitConfig -}: { - collapsed: boolean, - items: any[], - renderItem: (item: any, index: number) => any, - itemHeight?: number, - showDivider?: boolean, - dividerStyle?: StyleProp, - divider?: any, - additionalFirstItem?: ReactNode, - style?: StyleProp, - limitConfig?: CollapsibleCardsLimitConfig, -}) => { +}: AnimatedChildrenCollapsibleProps) => { const navigation = useTypedNavigation(); const theme = useTheme(); - const [itemsToRender, setItemsToRender] = useState([]); - const sharedHeight = useSharedValue(collapsed ? 0 : items.length * (itemHeight + (style as any)?.gap ?? 0)); + const itemsHeight = items.map((item) => item.height || itemHeight).reduce((a, b) => a + b, 0); + const gap = (style as any)?.gap || 0; + const height = itemsHeight + ((items.length - 1) * gap); + const sharedHeight = useSharedValue(collapsed ? 0 : height); const animStyle = useAnimatedStyle(() => { return { height: withTiming(sharedHeight.value, { duration: 250 }) }; }); useEffect(() => { - setItemsToRender(collapsed ? [] : items); - sharedHeight.value = collapsed ? 0 : items.length * (itemHeight + (style as any)?.gap ?? 0); - }, [collapsed, items]); + sharedHeight.value = collapsed ? 0 : height; + }, [collapsed, height]); const progress = useSharedValue(collapsed ? 0 : 1); @@ -95,13 +100,13 @@ export const AnimatedChildrenCollapsible = memo(({ {additionalFirstItem} )} - {itemsToRender.slice(0, limitConfig?.maxItems).map((item, index) => { + {items.slice(0, limitConfig?.maxItems).map((item, index) => { return ( {index === 0 && showDivider && !additionalFirstItem && ( divider @@ -121,7 +126,7 @@ export const AnimatedChildrenCollapsible = memo(({ ); })} - {!!limitConfig && (itemsToRender.length > limitConfig.maxItems) && ( + {!!limitConfig && (items.length > limitConfig.maxItems) && ( ); -}); \ No newline at end of file +}; + +export const AnimatedChildrenCollapsible = memo(AnimatedChildrenCollapsibleComponent) as typeof AnimatedChildrenCollapsibleComponent; \ No newline at end of file diff --git a/app/components/animated/CollapsibleCards.tsx b/app/components/animated/CollapsibleCards.tsx index 477e04a9a..143fa8ee1 100644 --- a/app/components/animated/CollapsibleCards.tsx +++ b/app/components/animated/CollapsibleCards.tsx @@ -18,7 +18,6 @@ const CardItemWrapper = memo(({ index: number, itemHeight?: number, }) => { - const animatedStyle = useAnimatedStyle(() => ({ marginTop: interpolate( progress.value, @@ -49,7 +48,18 @@ export type CollapsibleCardsLimitConfig = { fullList: ProductsListFragmentParams, } -export const CollapsibleCards = memo(({ +type CollapsibleCardsProps = { + title: string, + items: (T & { height?: number })[], + renderItem: (item: (T & { height?: number }), index: number) => any, + renderFace?: () => any, + itemHeight?: number, + theme: ThemeType, + initialCollapsed?: boolean, + limitConfig?: CollapsibleCardsLimitConfig +}; + +const CollapsibleCardsComponent = ({ title, items, renderItem, @@ -58,16 +68,7 @@ export const CollapsibleCards = memo(({ theme, initialCollapsed = true, limitConfig -}: { - title: string, - items: any[], - renderItem: (item: any, index: number) => any, - renderFace?: () => any, - itemHeight?: number, - theme: ThemeType, - initialCollapsed?: boolean, - limitConfig?: CollapsibleCardsLimitConfig -}) => { +}: CollapsibleCardsProps) => { const navigation = useTypedNavigation(); const dimentions = useWindowDimensions(); const [collapsed, setCollapsed] = useState(initialCollapsed); @@ -81,6 +82,18 @@ export const CollapsibleCards = memo(({ }); }, [collapsed]); + const firstItem = items[0]; + const secondItem = items[1]; + const thirdItem = items[2]; + + const firstHeight = firstItem?.height || itemHeight; + const secondHeight = secondItem?.height || itemHeight; + const thirdHeight = thirdItem?.height || itemHeight; + + const cardFirstItem = renderItem(firstItem, 0); + const cardSecondItem = renderItem(secondItem, 1); + const cardThirdItem = renderItem(thirdItem, 2); + const cardLevelOpacity = useAnimatedStyle(() => ({ opacity: interpolate( progress.value, @@ -91,11 +104,27 @@ export const CollapsibleCards = memo(({ pointerEvents: progress.value === 1 ? 'none' : 'auto' })); + const cardFirstLevelStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + progress.value, + [1, 0], + [1, 0], + Extrapolation.CLAMP, + ), + height: interpolate( + progress.value, + [0, 1], + [86, firstHeight], + Extrapolation.CLAMP + ), + pointerEvents: progress.value === 0 ? 'none' : 'auto' + })); + const cardSecondLevelStyle = useAnimatedStyle(() => ({ height: interpolate( progress.value, [0, 1], - [76, itemHeight], + [76, secondHeight], Extrapolation.CLAMP ), width: interpolate( @@ -107,7 +136,7 @@ export const CollapsibleCards = memo(({ marginTop: interpolate( progress.value, [0, 1], - [-66, 16 + itemHeight - 86], + [-66, 16 + firstHeight - 86], Extrapolation.CLAMP ), })); @@ -116,7 +145,7 @@ export const CollapsibleCards = memo(({ height: interpolate( progress.value, [0, 1], - [66, itemHeight], + [66, thirdHeight], Extrapolation.CLAMP ), width: interpolate( @@ -159,26 +188,6 @@ export const CollapsibleCards = memo(({ pointerEvents: progress.value === 1 ? 'none' : 'auto' })); - const cardFirstLevelStyle = useAnimatedStyle(() => ({ - opacity: interpolate( - progress.value, - [1, 0], - [1, 0], - Extrapolation.CLAMP, - ), - height: interpolate( - progress.value, - [0, 1], - [86, itemHeight], - Extrapolation.CLAMP - ), - pointerEvents: progress.value === 0 ? 'none' : 'auto' - })); - - const cardFirstItem = renderItem(items[0], 0); - const cardSecondItem = renderItem(items[1], 1); - const cardThirdItem = renderItem(items[2], 2); - return ( {items.slice(3, limitConfig?.maxItems).map((item, index) => { const itemView = renderItem(item, index); + const height = item.height || itemHeight; return ( ) })} @@ -320,4 +330,6 @@ export const CollapsibleCards = memo(({ )} ) -}); \ No newline at end of file +}; + +export const CollapsibleCards = memo(CollapsibleCardsComponent) as typeof CollapsibleCardsComponent; \ No newline at end of file diff --git a/app/components/avatar/Avatar.tsx b/app/components/avatar/Avatar.tsx index 4b5e36b27..a3c70f41f 100644 --- a/app/components/avatar/Avatar.tsx +++ b/app/components/avatar/Avatar.tsx @@ -65,7 +65,7 @@ export type AvatarIcProps = { size?: number, }; -function resolveIc( +export function resolveAvatarIc( params: { markContact?: boolean, isSpam?: boolean, @@ -231,7 +231,7 @@ export const Avatar = memo((props: { } let isSpam = showSpambadge && spam; - let ic = resolveIc({ markContact, verified, dontShowVerified, icProps, isSpam, icPosition, icSize, known: !!known, icOutline }, theme); + let ic = resolveAvatarIc({ markContact, verified, dontShowVerified, icProps, isSpam, icPosition, icSize, known: !!known, icOutline }, theme); if (image) { img = ( diff --git a/app/components/avatar/ForcedAvatar.tsx b/app/components/avatar/ForcedAvatar.tsx index a29f69d31..21138a9f5 100644 --- a/app/components/avatar/ForcedAvatar.tsx +++ b/app/components/avatar/ForcedAvatar.tsx @@ -1,32 +1,80 @@ import { memo } from "react"; import { Image } from 'expo-image'; +import { PerfView } from "../basic/PerfView"; +import { useTheme } from "../../engine/hooks"; +import { AvatarIcProps, resolveAvatarIc } from "./Avatar"; export type ForcedAvatarType = 'dedust' | 'holders' | 'ledger'; -export const ForcedAvatar = memo(({ type, size }: { type: ForcedAvatarType, size: number }) => { +export const ForcedAvatar = memo(({ + type, + size, + hideVerifIcon, + icProps +}: { + type: ForcedAvatarType, + size: number, + hideVerifIcon?: boolean, + icProps?: AvatarIcProps +}) => { + const theme = useTheme(); + let icSize = icProps?.size ?? Math.floor(size * 0.43); + let icOutline = Math.round(icSize * 0.03) > 2 ? Math.round(icSize * 0.03) : 2; + if (!!icProps?.borderWidth) { + icOutline = icProps?.borderWidth; + } + const icOffset = -(icSize - icOutline) / 2; + let icPosition: { top?: number, bottom?: number, left?: number, right?: number } = { bottom: -2, right: -2 }; + + switch (icProps?.position) { + case 'top': + icPosition = { top: icOffset }; + break; + case 'left': + icPosition = { bottom: -icOutline, left: -icOutline }; + break; + case 'right': + icPosition = { bottom: -icOutline, right: -icOutline }; + break; + case 'bottom': + icPosition = { bottom: icOffset }; + break; + } + let verifIcon = hideVerifIcon + ? null + : resolveAvatarIc({ verified: true, icProps, icPosition, icSize, icOutline }, theme); + let img = null; switch (type) { case 'dedust': - return ( - - ); + img = + break; case 'holders': - return ( - - ); + img = + break; case 'ledger': - return ( - - ); - default: return null; + img = + break; } + + return ( + + {img} + {verifIcon} + + ); }); \ No newline at end of file diff --git a/app/components/avatar/PendingTransactionAvatar.tsx b/app/components/avatar/PendingTransactionAvatar.tsx index 36477296c..a8db4f885 100644 --- a/app/components/avatar/PendingTransactionAvatar.tsx +++ b/app/components/avatar/PendingTransactionAvatar.tsx @@ -62,7 +62,7 @@ export const PendingTransactionAvatar = memo(({ justifyContent: 'center' }}> {!!forceAvatar ? ( - + ) : ( {(banner.title || banner.description || banner.icon_url) && ( {banner.icon_url && ( )} diff --git a/app/components/browser/BrowserBanners.tsx b/app/components/browser/BrowserBanners.tsx index d168d4e05..3196f0d2c 100644 --- a/app/components/browser/BrowserBanners.tsx +++ b/app/components/browser/BrowserBanners.tsx @@ -6,7 +6,6 @@ import { BrowserBanner } from "./BrowserBanner"; import { useSharedValue } from "react-native-reanimated"; import { useTheme } from "../../engine/hooks"; import { useTypedNavigation } from "../../utils/useTypedNavigation"; -import { MixpanelEvent, trackEvent } from "../../analytics/mixpanel"; export const BrowserBanners = memo(({ banners }: { banners: BrowserBannerItem[] }) => { const dimensions = useDimensions(); @@ -17,7 +16,7 @@ export const BrowserBanners = memo(({ banners }: { banners: BrowserBannerItem[] const isPressed = useRef(false); const [activeSlide, setActiveSlide] = useState(0); - const [scrollViewWidth, setScrollViewWidth] = useState(0); + const scrollViewWidth = dimensions.screen.width const boxWidth = scrollViewWidth * 0.85; const boxDistance = scrollViewWidth - boxWidth; const halfBoxDistance = boxDistance / 2; @@ -27,10 +26,6 @@ export const BrowserBanners = memo(({ banners }: { banners: BrowserBannerItem[] useEffect(() => { if (banners.length === 0) return; - if (banners[activeSlide]) { - trackEvent(MixpanelEvent.BrowserBannerShown, { id: banners[activeSlide].id, productUrl: banners[activeSlide].product_url }); - } - const timerId = setTimeout(() => { if (banners.length === 0) return; if (activeSlide < banners.length - 1 && !isPressed.current) { @@ -72,9 +67,6 @@ export const BrowserBanners = memo(({ banners }: { banners: BrowserBannerItem[] onScrollBeginDrag={() => isPressed.current = true} onScrollEndDrag={() => isPressed.current = false} contentOffset={{ x: halfBoxDistance * -1, y: 0 }} - onLayout={(e) => { - setScrollViewWidth(e.nativeEvent.layout.width); - }} snapToAlignment={'center'} keyExtractor={(item, index) => `banner-${index}-${item.id}`} onScroll={(e) => { diff --git a/app/components/browser/BrowserTabs.tsx b/app/components/browser/BrowserTabs.tsx index 90c9d6617..4b6357426 100644 --- a/app/components/browser/BrowserTabs.tsx +++ b/app/components/browser/BrowserTabs.tsx @@ -6,13 +6,56 @@ import { useTheme } from "../../engine/hooks"; import { BrowserExtensions } from "./BrowserExtensions"; import { BrowserListings } from "./BrowserListings"; import { BrowserConnections } from "./BrowserConnections"; -import { useBrowserListings } from "../../engine/hooks/banners/useBrowserListings"; +import { BrowserListingsWithCategory, useBrowserListings } from "../../engine/hooks/banners/useBrowserListings"; import { ScrollView } from "react-native-gesture-handler"; import { NativeSyntheticEvent } from "react-native"; +import { getCountryCodes } from "../../utils/isNeocryptoAvailable"; + +function filterByStoreGeoListings(codes: { countryCode: string, storeFrontCode: string | null },) { + return (listing: BrowserListingsWithCategory) => { + const { countryCode, storeFrontCode } = codes; + + let excludedRegions = [], includedRegions: string[] | null = null; + try { + excludedRegions = JSON.parse(listing.regions_to_exclude || '[]'); + } catch { } + + if (!!listing.regions_to_include) { + try { + includedRegions = JSON.parse(listing.regions_to_include); + } catch { } + } + + // check for excluded regions + const excludedByStore = !!storeFrontCode && excludedRegions.includes(storeFrontCode); + const excludedByCountry = !!countryCode && excludedRegions.includes(countryCode); + + if (excludedByStore || excludedByCountry) { + return false; + } + + if (includedRegions === null) { + return true; + } + + // check for included regions + const includedByStore = !!storeFrontCode ? includedRegions.includes(storeFrontCode) : false; + const includedByCountry = !!countryCode ? includedRegions.includes(countryCode) : false; + + if (!includedByStore && !includedByCountry) { + return false; + } + + return true; + } +} export const BrowserTabs = memo(({ onScroll }: { onScroll?: ((event: NativeSyntheticEvent) => void) }) => { const theme = useTheme(); - const listings = useBrowserListings().data || []; + const browserListings = useBrowserListings().data || []; + const regionCodes = getCountryCodes(); + const filterByCodes = useCallback(filterByStoreGeoListings(regionCodes), [regionCodes]); + const listings = browserListings.filter(filterByCodes); const hasListings = !!listings && listings.length > 0; const tabRef = useRef(hasListings ? 0 : 1); const [tab, setTab] = useState(tabRef.current); diff --git a/app/components/products/HoldersAccountCard.tsx b/app/components/products/HoldersAccountCard.tsx index 5b5989ec3..376afe519 100644 --- a/app/components/products/HoldersAccountCard.tsx +++ b/app/components/products/HoldersAccountCard.tsx @@ -1,9 +1,10 @@ import { memo } from "react"; -import { View, Image, Text } from "react-native"; +import { Image } from "react-native"; import { GeneralHoldersCard } from "../../engine/api/holders/fetchAccounts"; import { ThemeType } from "../../engine/state/theme"; import { PerfView } from "../basic/PerfView"; import { PerfText } from "../basic/PerfText"; +import { useLockAppWithAuthState } from "../../engine/hooks/settings"; const cardImages = { 'dark': { @@ -21,28 +22,31 @@ const cardImages = { } export const HoldersAccountCard = memo(({ card, theme }: { card: GeneralHoldersCard, theme: ThemeType }) => { - let imageType: 'holders' | 'classic' | 'whales' | 'black-pro' = 'classic'; - switch (card.personalizationCode) { - case 'holders': - imageType = 'holders'; - break; - case 'whales': - imageType = 'whales'; - break; - case 'black-pro': - imageType = 'black-pro'; - break; - default: - imageType = 'classic'; - break; - } + const [lockAppWithAuth] = useLockAppWithAuthState(); + // TODO: remove this when we have the correct personalization code + // let imageType: 'holders' | 'classic' | 'whales' | 'black-pro' = 'classic'; + let imageType: 'holders' | 'classic' | 'whales' | 'black-pro' = 'black-pro'; + // switch (card.personalizationCode) { + // case 'holders': + // imageType = 'holders'; + // break; + // case 'whales': + // imageType = 'whales'; + // break; + // case 'black-pro': + // imageType = 'black-pro'; + // break; + // default: + // imageType = 'classic'; + // break; + // } return ( {!!card.lastFourDigits && ( - {card.lastFourDigits} + {lockAppWithAuth ? card.lastFourDigits : '****'} )} diff --git a/app/components/products/HoldersAccountItem.tsx b/app/components/products/HoldersAccountItem.tsx index aef71fa21..6b1005d2a 100644 --- a/app/components/products/HoldersAccountItem.tsx +++ b/app/components/products/HoldersAccountItem.tsx @@ -87,12 +87,12 @@ export const HoldersAccountItem = memo((props: { if (needsEnrollment) { const onEnrollType: HoldersAppParams = { type: 'account', id: props.account.id }; - navigation.navigateHoldersLanding({ endpoint: url, onEnrollType }); + navigation.navigateHoldersLanding({ endpoint: url, onEnrollType }, props.isTestnet); return; } - navigation.navigateHolders({ type: 'account', id: props.account.id }); - }, [props.account, needsEnrollment]); + navigation.navigateHolders({ type: 'account', id: props.account.id }, props.isTestnet); + }, [props.account, needsEnrollment, props.isTestnet]); const { onPressIn, onPressOut, animatedStyle } = useAnimatedPressedInOut(); @@ -146,7 +146,7 @@ export const HoldersAccountItem = memo((props: { @@ -178,9 +178,10 @@ export const HoldersAccountItem = memo((props: { {` ${props.account.cryptoCurrency?.ticker}`} diff --git a/app/components/products/HoldersAccounts.tsx b/app/components/products/HoldersAccounts.tsx index 3acbb2c9d..f7bd5d386 100644 --- a/app/components/products/HoldersAccounts.tsx +++ b/app/components/products/HoldersAccounts.tsx @@ -34,6 +34,10 @@ export const HoldersAccounts = memo(({ return reduceHoldersBalances(accs, price?.price?.usd ?? 0); }, [accs, price?.price?.usd]); + if (accs.length === 0) { + return null; + } + if (accs.length < 3) { return ( @@ -70,7 +74,10 @@ export const HoldersAccounts = memo(({ return ( { + return { ...item, height: item.cards.length > 0 ? 122 : 86 } + })} renderItem={(item, index) => { return ( markAccount(item.id, true)} isTestnet={isTestnet} holdersAccStatus={holdersAccStatus} + hideCardsIfEmpty /> ) }} diff --git a/app/components/products/HoldersHiddenProductComponent.tsx b/app/components/products/HoldersHiddenProductComponent.tsx index 8b06dcf66..3d82124f2 100644 --- a/app/components/products/HoldersHiddenProductComponent.tsx +++ b/app/components/products/HoldersHiddenProductComponent.tsx @@ -27,7 +27,7 @@ export const HoldersHiddenProductComponent = memo(({ holdersAccStatus }: { holde const [hiddenAccounts, markAccount] = useHoldersHiddenAccounts(selected!.address); const [hiddenPrepaidCards, markPrepaidCard] = useHoldersHiddenPrepaidCards(selected!.address); - const hiddenAccountsList = useMemo(() => { + let hiddenAccountsList = useMemo(() => { return (accounts ?? []).filter((item) => { return hiddenAccounts.includes(item.id); }); @@ -76,7 +76,10 @@ export const HoldersHiddenProductComponent = memo(({ holdersAccStatus }: { holde { + return { ...item, height: item.cards.length > 0 ? 122 : 86 } + })} itemHeight={122} style={{ gap: 16, paddingHorizontal: 16 }} renderItem={(item, index) => { @@ -90,9 +93,10 @@ export const HoldersHiddenProductComponent = memo(({ holdersAccStatus }: { holde rightAction={() => markAccount(item.id, false)} rightActionIcon={} single={hiddenAccountsList.length === 1} - style={{ paddingVertical: 0 }} + style={{ flex: undefined }} isTestnet={network.isTestnet} holdersAccStatus={holdersAccStatus} + hideCardsIfEmpty /> ) }} diff --git a/app/components/products/HoldersPrepaidCard.tsx b/app/components/products/HoldersPrepaidCard.tsx index 75cc0b827..90461f302 100644 --- a/app/components/products/HoldersPrepaidCard.tsx +++ b/app/components/products/HoldersPrepaidCard.tsx @@ -16,6 +16,7 @@ import { CurrencySymbols } from "../../utils/formatCurrency"; import { HoldersAccountCard } from "./HoldersAccountCard"; import { HoldersAccountStatus } from "../../engine/hooks/holders/useHoldersAccountStatus"; import { HoldersAppParams } from "../../fragments/holders/HoldersAppFragment"; +import { useLockAppWithAuthState } from "../../engine/hooks/settings"; export const HoldersPrepaidCard = memo((props: { card: PrePaidHoldersCard, @@ -31,6 +32,7 @@ export const HoldersPrepaidCard = memo((props: { holdersAccStatus?: HoldersAccountStatus, onBeforeOpen?: () => void }) => { + const [lockAppWithAuth] = useLockAppWithAuthState(); const card = props.card; const swipableRef = useRef(null); const theme = useTheme(); @@ -61,16 +63,16 @@ export const HoldersPrepaidCard = memo((props: { if (needsEnrollment) { const onEnrollType: HoldersAppParams = { type: 'prepaid', id: card.id }; - navigation.navigateHoldersLanding({ endpoint: url, onEnrollType }); + navigation.navigateHoldersLanding({ endpoint: url, onEnrollType }, props.isTestnet); return; } - navigation.navigateHolders({ type: 'prepaid', id: card.id }); - }, [card, needsEnrollment, props.onBeforeOpen]); + navigation.navigateHolders({ type: 'prepaid', id: card.id }, props.isTestnet); + }, [card, needsEnrollment, props.onBeforeOpen, props.isTestnet]); const { onPressIn, onPressOut, animatedStyle } = useAnimatedPressedInOut(); - const title = t('products.holders.accounts.prepaidCard', { lastFourDigits: card.lastFourDigits }); + const title = t('products.holders.accounts.prepaidCard', { lastFourDigits: lockAppWithAuth ? card.lastFourDigits : '****' }); const subtitle = t('products.holders.accounts.prepaidCardDescription'); const renderRightAction = (!!props.rightActionIcon && !!props.rightAction) @@ -125,7 +127,8 @@ export const HoldersPrepaidCard = memo((props: { {title} @@ -140,9 +143,19 @@ export const HoldersPrepaidCard = memo((props: { - + - + {lockAppWithAuth ? ( + + ) : ( + + {'****'} + + )} {` ${CurrencySymbols[card.fiatCurrency].symbol}`} diff --git a/app/components/products/HoldersProductComponent.tsx b/app/components/products/HoldersProductComponent.tsx index 70a8cbe4c..1292da219 100644 --- a/app/components/products/HoldersProductComponent.tsx +++ b/app/components/products/HoldersProductComponent.tsx @@ -28,12 +28,15 @@ export const HoldersProductComponent = memo(({ holdersAccStatus }: { holdersAccS }); }, [hiddenPrepaidCards, prePaid]); - if (visibleAccountsList?.length === 0 && visiblePrepaidList?.length === 0) { + const hasAccounts = visibleAccountsList?.length > 0; + const hasPrepaid = visiblePrepaidList?.length > 0; + + if (!hasAccounts && !hasPrepaid) { return null; } return ( - 0 ? 16 : 0 }}> + - 0 ? 16 : 0 }}> + ) : ( @@ -65,7 +65,7 @@ export const JettonIcon = memo(({ ) : ( @@ -100,7 +100,7 @@ export const JettonIcon = memo(({ {isKnown ? ( diff --git a/app/components/products/JettonsHiddenComponent.tsx b/app/components/products/JettonsHiddenComponent.tsx index 5e23a4f95..329a317ef 100644 --- a/app/components/products/JettonsHiddenComponent.tsx +++ b/app/components/products/JettonsHiddenComponent.tsx @@ -59,7 +59,8 @@ export const JettonsHiddenComponent = memo(({ owner }: { owner: Address }) => { showDivider={false} collapsed={collapsed} items={hiddenList} - itemHeight={102} + itemHeight={86} + style={{ gap: 16, paddingHorizontal: 16 }} renderItem={(j, index) => { const length = hiddenList.length >= 4 ? 4 : hiddenList.length; const isLast = index === length - 1; @@ -69,7 +70,7 @@ export const JettonsHiddenComponent = memo(({ owner }: { owner: Address }) => { wallet={j} first={index === 0} last={isLast} - itemStyle={{ marginHorizontal: 16, marginBottom: 16 }} + itemStyle={{ borderRadius: 20 }} rightAction={() => markJettonActive(j)} rightActionIcon={} single={hiddenList.length === 1} diff --git a/app/components/products/JettonsList.tsx b/app/components/products/JettonsList.tsx index 4557cd546..d29df7da1 100644 --- a/app/components/products/JettonsList.tsx +++ b/app/components/products/JettonsList.tsx @@ -20,6 +20,7 @@ import Animated, { Easing, LinearTransition, useAnimatedStyle, useSharedValue, w import { PerfView } from "../basic/PerfView"; import { LoadingIndicator } from "../LoadingIndicator"; import { filterHint, getHint, HintsFilter } from "../../utils/hintSortFilter"; +import { queryClient } from "../../engine/clients"; const EmptyListItem = memo(() => { const theme = useTheme(); @@ -111,9 +112,10 @@ export const JettonsList = memo(({ isLedger }: { isLedger: boolean }) => { const [filteredJettons, setFilteredJettons] = useState(jettons); useEffect(() => { + const cache = queryClient.getQueryCache(); setFilteredJettons( jettons - .map((h) => getHint(h, testOnly)) + .map((h) => getHint(cache, h, testOnly)) .filter(filterHint(filter)).map((x) => x.address) ); diff --git a/app/components/products/LedgerJettonsProductComponent.tsx b/app/components/products/LedgerJettonsProductComponent.tsx index 76890fcfe..ff698fe4d 100644 --- a/app/components/products/LedgerJettonsProductComponent.tsx +++ b/app/components/products/LedgerJettonsProductComponent.tsx @@ -65,12 +65,9 @@ export const LedgerJettonsProductComponent = memo(({ address, testOnly }: { addr title={t('jetton.productButtonTitle')} items={jettons} renderItem={(j) => { - if (!j) { - return null; - } - return ( + return !j ? null : ( { if (needsEnrolment || !isHoldersReady) { - navigation.navigateHoldersLanding({ endpoint: url, onEnrollType: { type: 'create' } }); + navigation.navigateHoldersLanding({ endpoint: url, onEnrollType: { type: 'create' } }, isTestnet); return; } - navigation.navigateHolders({ type: 'create' }); - }, [needsEnrolment, isHoldersReady]); + navigation.navigateHolders({ type: 'create' }, isTestnet); + }, [needsEnrolment, isHoldersReady, isTestnet]); const onProductBannerPress = useCallback((product: ProductAd) => { trackEvent( diff --git a/app/components/products/SpecialJettonProduct.tsx b/app/components/products/SpecialJettonProduct.tsx index 369458f5c..98a2c8019 100644 --- a/app/components/products/SpecialJettonProduct.tsx +++ b/app/components/products/SpecialJettonProduct.tsx @@ -101,7 +101,7 @@ export const SpecialJettonProduct = memo(({ void, reject: () => void } + promise: { resolve: (res: { keys: WalletKeys, passcode: string }) => void, reject: (reason?: AuthRejectReason) => void } params?: AuthParams } | { returns: 'keysOnly', - promise: { resolve: (keys: WalletKeys) => void, reject: () => void } + promise: { resolve: (keys: WalletKeys) => void, reject: (reason?: AuthRejectReason) => void } params?: AuthParams } export type AuthWalletKeysType = { authenticate: (style?: AuthParams) => Promise, - authenticateWithPasscode: (style?: AuthParams) => Promise<{ keys: WalletKeys, passcode: string }>, + authenticateWithPasscode: (style?: AuthParams) => Promise<{ keys: WalletKeys, passcode: string }> } export async function checkBiometricsPermissions(passcodeState: PasscodeState | null): Promise<'use-passcode' | 'biometrics-setup-again' | 'biometrics-permission-check' | 'biometrics-cooldown' | 'biometrics-cancelled' | 'corrupted' | 'none'> { @@ -128,6 +136,7 @@ export const AuthWalletKeysContext = createContext(nu export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => { const navigation = useTypedNavigation(); const { showActionSheetWithOptions } = useActionSheet(); + const { setAuthInProgress } = useAppBlur(); const safeAreaInsets = useSafeAreaInsets(); const theme = useTheme(); const logOutAndReset = useLogoutAndReset(); @@ -141,7 +150,7 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => // Reject previous auth promise if (auth) { - auth.promise.reject(); + auth.promise.reject(AuthRejectReason.InProgress); } // Clear previous auth @@ -156,6 +165,7 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => // If biometrics are not available, shows proper alert to user or throws an error if (useBiometrics) { try { + setAuthInProgress(true); const acc = style?.selectedAccount ?? getCurrentAddress(); const keys = await loadWalletKeys(acc.secretKeyEnc); if (biometricsState === null) { @@ -242,6 +252,8 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => // Overwise, premissionsRes: 'biometrics-cancelled' |'none' | 'use-passcode' // -> Perform fallback to passcode } + } finally { + setAuthInProgress(false); } } @@ -251,9 +263,11 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => const resolveWithTimestamp = async (keys: WalletKeys) => { updateLastAuthTimestamp(); + setAuthInProgress(false); resolve(keys); }; + setAuthInProgress(true); setAuth({ returns: 'keysOnly', promise: { resolve: resolveWithTimestamp, reject }, params: { showResetOnMaxAttempts: true, ...style, useBiometrics, passcodeLength } }); }); } @@ -266,7 +280,7 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => // Reject previous auth promise if (auth) { - auth.promise.reject(); + auth.promise.reject(AuthRejectReason.InProgress); } // Clear previous auth @@ -277,7 +291,7 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => return new Promise<{ keys: WalletKeys, passcode: string }>((resolve, reject) => { const passcodeState = getPasscodeState(); if (passcodeState !== PasscodeState.Set) { - reject(); + reject(AuthRejectReason.PasscodeNotSet); } const resolveWithTimestamp = async (res: { @@ -321,6 +335,32 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => setAttempts(0); }, [auth]); + const canRetryBiometrics = !!auth && ( + auth.params?.useBiometrics + && auth.returns === 'keysOnly' + && biometricsState === BiometricsState.InUse + ); + + const retryBiometrics = useCallback(async () => { + if (!canRetryBiometrics) { + return; + } + try { + const acc = getCurrentAddress(); + let keys = await loadWalletKeys(acc.secretKeyEnc); + auth.promise.resolve(keys); + // Remove auth view + setAuth(null); + } catch (e) { + if (e instanceof SecureAuthenticationCancelledError) { + return; + } else { + Alert.alert(t('secure.onBiometricsError')); + warn('Failed to load wallet keys'); + } + } + }, [canRetryBiometrics]); + return ( {props.children} @@ -347,7 +387,7 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => description={auth.params?.description} onEntered={async (pass) => { if (!pass) { - auth.promise.reject(); + auth.promise.reject(AuthRejectReason.NoPasscode); setAuth(null); return; } @@ -379,31 +419,12 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => : undefined } passcodeLength={auth.params?.passcodeLength} - onRetryBiometrics={ - (auth.params?.useBiometrics && auth.returns === 'keysOnly' && biometricsState === BiometricsState.InUse) - ? async () => { - try { - const acc = getCurrentAddress(); - let keys = await loadWalletKeys(acc.secretKeyEnc); - auth.promise.resolve(keys); - // Remove auth view - setAuth(null); - } catch (e) { - if (e instanceof SecureAuthenticationCancelledError) { - return; - } else { - Alert.alert(t('secure.onBiometricsError')); - warn('Failed to load wallet keys'); - } - } - } - : undefined - } + onRetryBiometrics={canRetryBiometrics ? retryBiometrics : undefined} /> {auth.params?.cancelable && ( { - auth.promise.reject(); + auth.promise.reject(AuthRejectReason.Canceled); setAuth(null); }} style={{ diff --git a/app/components/staking/LiquidStakingPool.tsx b/app/components/staking/LiquidStakingPool.tsx index 747a19acb..05df840a8 100644 --- a/app/components/staking/LiquidStakingPool.tsx +++ b/app/components/staking/LiquidStakingPool.tsx @@ -162,7 +162,7 @@ export const LiquidStakingPool = memo(( diff --git a/app/components/staking/StakingPool.tsx b/app/components/staking/StakingPool.tsx index bf781e44c..b8b2a0d71 100644 --- a/app/components/staking/StakingPool.tsx +++ b/app/components/staking/StakingPool.tsx @@ -166,7 +166,7 @@ export const StakingPool = memo((props: { diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index e028befb3..655afcbce 100644 --- a/app/components/webview/DAppWebView.tsx +++ b/app/components/webview/DAppWebView.tsx @@ -7,7 +7,7 @@ import { useTypedNavigation } from "../../utils/useTypedNavigation"; import { EdgeInsets, useSafeAreaInsets } from "react-native-safe-area-context"; import { DappMainButton, processMainButtonMessage, reduceMainButton } from "../DappMainButton"; import Animated, { FadeInDown, FadeOut, FadeOutDown } from "react-native-reanimated"; -import { authAPI, dispatchAuthResponse, dispatchLastAuthTimeResponse, dispatchMainButtonResponse, dispatchResponse, dispatchTonhubBridgeResponse, emitterAPI, mainButtonAPI, statusBarAPI, toasterAPI } from "../../fragments/apps/components/inject/createInjectSource"; +import { authAPI, dispatchAuthResponse, dispatchLastAuthTimeResponse, dispatchLockAppWithAuthResponse, dispatchMainButtonResponse, dispatchResponse, dispatchTonhubBridgeResponse, emitterAPI, mainButtonAPI, statusBarAPI, toasterAPI } from "../../fragments/apps/components/inject/createInjectSource"; import { warn } from "../../utils/log"; import { extractDomain } from "../../engine/utils/extractDomain"; import { openWithInApp } from "../../utils/openWithInApp"; @@ -22,6 +22,7 @@ import DeviceInfo from 'react-native-device-info'; import { processEmitterMessage } from "./utils/processEmitterMessage"; import { getLastAuthTimestamp, useKeysAuth } from "../secure/AuthWalletKeys"; import { getLockAppWithAuthState } from "../../engine/state/lockAppWithAuthState"; +import { useLockAppWithAuthState } from "../../engine/hooks/settings"; export type DAppWebViewProps = WebViewProps & { useMainButton?: boolean; @@ -66,6 +67,7 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar const navigation = useTypedNavigation(); const toaster = useToaster(); const markRefIdShown = useMarkBannerHidden(); + const [, setLockAppWithAuth] = useLockAppWithAuthState(); const [loaded, setLoaded] = useState(false); @@ -166,19 +168,25 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar dispatchLastAuthTimeResponse(ref as RefObject, getLastAuthTimestamp() || 0); } else if (method === 'authenticate') { (async () => { - let authenicated = false; + let isAuthenticated = false; let lastAuthTime: number | undefined; // wait for auth to complete try { - await authContext.authenticate(); - authenicated = true; + await authContext.authenticate({ cancelable: true, paddingTop: 32 }); + isAuthenticated = true; lastAuthTime = getLastAuthTimestamp(); } catch { warn('Failed to authenticate'); } // Dispatch response - dispatchAuthResponse(ref as RefObject, { authenicated, lastAuthTime }); + dispatchAuthResponse(ref as RefObject, { isAuthenticated, lastAuthTime }); })(); + } else if (method === 'lockAppWithAuth') { + const callback = (isSecured: boolean) => { + const lastAuthTime = getLastAuthTimestamp(); + dispatchLockAppWithAuthResponse(ref as RefObject, { isSecured, lastAuthTime }); + } + navigation.navigateMandatoryAuthSetup({ callback }); } return; @@ -367,7 +375,7 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar ${props.useEmitter ? emitterAPI : ''} ${props.useAuthApi ? authAPI({ lastAuthTime: getLastAuthTimestamp(), - isLockedByAuth: getLockAppWithAuthState() ?? false + isSecured: getLockAppWithAuthState() }) : ''} ${props.injectedJavaScriptBeforeContentLoaded ?? ''} (() => { diff --git a/app/engine/api/fetchBrowserListings.ts b/app/engine/api/fetchBrowserListings.ts index 4ba27a615..9e787df93 100644 --- a/app/engine/api/fetchBrowserListings.ts +++ b/app/engine/api/fetchBrowserListings.ts @@ -13,6 +13,7 @@ const browserListingCodec = z.object({ start_date: z.number(), expiration_date: z.number(), regions_to_exclude: z.string().nullable().optional(), + regions_to_include: z.string().nullable().optional(), enabled: z.boolean(), category: z.string().nullable().optional(), is_test: z.boolean().nullable().optional() diff --git a/app/engine/api/sendTonConnectResponse.ts b/app/engine/api/sendTonConnectResponse.ts index fb01919d5..0707a1ceb 100644 --- a/app/engine/api/sendTonConnectResponse.ts +++ b/app/engine/api/sendTonConnectResponse.ts @@ -29,7 +29,7 @@ export async function sendTonConnectResponse({ ); await axios.post(url, Base64.encode(encodedResponse), { headers: { 'Content-Type': 'text/plain' } }); - } catch (e) { + } catch { warn('Failed to send TonConnect response'); } } \ No newline at end of file diff --git a/app/engine/hooks/dapps/useConnectCallback.ts b/app/engine/hooks/dapps/useConnectCallback.ts index 7d88a9621..c18c77218 100644 --- a/app/engine/hooks/dapps/useConnectCallback.ts +++ b/app/engine/hooks/dapps/useConnectCallback.ts @@ -4,32 +4,21 @@ import { sendTonConnectResponse } from "../../api/sendTonConnectResponse"; import { useDeleteActiveRemoteRequests } from "./useDeleteActiveRemoteRequests"; import { SendTransactionError, SendTransactionRequest } from '../../tonconnect/types'; +const errorMessage = 'Wallet declined request'; + export function useConnectCallback() { const deleteActiveRemoteRequests = useDeleteActiveRemoteRequests(); - return ( + return async ( ok: boolean, result: Cell | null, request: { from: string } & SendTransactionRequest, sessionCrypto: SessionCrypto ) => { - if (!ok) { - sendTonConnectResponse({ - response: new SendTransactionError( - request.id, - SEND_TRANSACTION_ERROR_CODES.USER_REJECTS_ERROR, - 'Wallet declined the request', - ), - sessionCrypto, - clientSessionId: request.from - }); - } else { - sendTonConnectResponse({ - response: { result: result?.toBoc({ idx: false }).toString('base64') ?? '', id: request.id }, - sessionCrypto, - clientSessionId: request.from - }); - } + const response = !ok + ? new SendTransactionError(request.id, SEND_TRANSACTION_ERROR_CODES.USER_REJECTS_ERROR, errorMessage) + : { result: result?.toBoc({ idx: false }).toString('base64') ?? '', id: request.id }; + await sendTonConnectResponse({ response, sessionCrypto, clientSessionId: request.from }); deleteActiveRemoteRequests(request.from); } } \ No newline at end of file diff --git a/app/engine/hooks/dapps/useConnectPendingRequests.ts b/app/engine/hooks/dapps/useConnectPendingRequests.ts index 7d8582eca..b1fc42f12 100644 --- a/app/engine/hooks/dapps/useConnectPendingRequests.ts +++ b/app/engine/hooks/dapps/useConnectPendingRequests.ts @@ -3,6 +3,5 @@ import { pendingRequestsSelector } from "../../state/tonconnect"; import { SendTransactionRequest } from '../../tonconnect/types'; export function useConnectPendingRequests(): [SendTransactionRequest[], (updater: (currVal: SendTransactionRequest[]) => SendTransactionRequest[]) => void] { - const [value, update] = useRecoilState(pendingRequestsSelector); - return [value, update]; + return useRecoilState(pendingRequestsSelector); } \ No newline at end of file diff --git a/app/engine/hooks/dapps/useHandleMessage.ts b/app/engine/hooks/dapps/useHandleMessage.ts index 3db61190c..3d9cd4179 100644 --- a/app/engine/hooks/dapps/useHandleMessage.ts +++ b/app/engine/hooks/dapps/useHandleMessage.ts @@ -18,7 +18,6 @@ export function useHandleMessage( const disconnectApp = useDisconnectApp(); return async (event: MessageEvent) => { - logger.log(`sse connect message: type ${event}`); try { if (event.lastEventId) { setLastEventId(event.lastEventId); @@ -61,6 +60,7 @@ export function useHandleMessage( }, id: request.id.toString(), }); + return; } if (request.method === 'sendTransaction') { @@ -131,7 +131,7 @@ export function useHandleMessage( }); } - } catch (e) { + } catch { warn('Failed to handle message'); } } diff --git a/app/engine/hooks/dapps/usePrepareConnectRequest.ts b/app/engine/hooks/dapps/usePrepareConnectRequest.ts index 854c63fb5..529840c1e 100644 --- a/app/engine/hooks/dapps/usePrepareConnectRequest.ts +++ b/app/engine/hooks/dapps/usePrepareConnectRequest.ts @@ -3,10 +3,13 @@ import { CHAIN, SEND_TRANSACTION_ERROR_CODES, SessionCrypto } from "@tonconnect/ import { sendTonConnectResponse } from "../../api/sendTonConnectResponse"; import { getTimeSec } from "../../../utils/getTimeSec"; import { warn } from "../../../utils/log"; -import { Cell, fromNano, toNano } from "@ton/core"; +import { Address, Cell, fromNano, toNano } from "@ton/core"; import { useDeleteActiveRemoteRequests } from "./useDeleteActiveRemoteRequests"; import { SendTransactionRequest, SignRawParams } from '../../tonconnect/types'; import { ConnectedApp } from "./useTonConnectExtenstions"; +import { Toaster } from "../../../components/toast/ToastProvider"; +import { getCurrentAddress } from "../../../storage/appState"; +import { t } from "../../../i18n/t"; export type PreparedConnectRequest = { request: SendTransactionRequest, @@ -23,9 +26,11 @@ export type PreparedConnectRequest = { from?: string } -export function usePrepareConnectRequest(): (request: { from: string } & SendTransactionRequest) => PreparedConnectRequest | undefined { +// check if the request is valid and prepare the request for transfer fragment navigation +export function usePrepareConnectRequest(config: { isTestnet: boolean, toaster: Toaster, toastProps?: { marginBottom: number } }): (request: { from: string } & SendTransactionRequest) => PreparedConnectRequest | undefined { const findConnectedAppByClientSessionId = useConnectAppByClientSessionId(); const deleteActiveRemoteRequest = useDeleteActiveRemoteRequests(); + const { toaster, isTestnet, toastProps } = config; return (request: { from: string } & SendTransactionRequest) => { const params = JSON.parse(request.params[0]) as SignRawParams; @@ -40,38 +45,87 @@ export function usePrepareConnectRequest(): (request: { from: string } & SendTra deleteActiveRemoteRequest(request.from); return; } + const sessionCrypto = new SessionCrypto(session.sessionKeyPair); + const toasterErrorProps: { type: 'error', marginBottom?: number } = { type: 'error', marginBottom: toastProps?.marginBottom }; + const walletNetwork = isTestnet ? CHAIN.TESTNET : CHAIN.MAINNET; - if (!isValidRequest) { + const deleteAndReportError = async (message: string, code: SEND_TRANSACTION_ERROR_CODES, toastMessage: string) => { + // remove request from active requests locally deleteActiveRemoteRequest(request.from); - sendTonConnectResponse({ - response: { - error: { - code: SEND_TRANSACTION_ERROR_CODES.UNKNOWN_ERROR, - message: `Bad request`, - }, - id: request.id.toString(), - }, - sessionCrypto, - clientSessionId: request.from - }) + + // show error message to the user + toaster.show({ ...toasterErrorProps, message: toastMessage }); + + // send error response to the dApp client + try { + await sendTonConnectResponse({ + response: { error: { code, message }, id: request.id.toString() }, + sessionCrypto, + clientSessionId: request.from + }); + } catch { + toaster.push({ + ...toasterErrorProps, + message: t('products.transactionRequest.failedToReportCanceled'), + }); + } + } + + let { valid_until, network, from } = params; + + // check if the network is the same as the current wallet network + if (!!network) { + if (network !== walletNetwork) { + deleteAndReportError( + 'Invalid from address', + SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + t('products.transactionRequest.wrongNetwork') + ); + return; + } + } + + // check if the from address is the same as the current wallet address + if (!!from) { + const current = getCurrentAddress(); + try { + const fromAddress = Address.parse(from); + + if (!fromAddress.equals(current.address)) { + deleteAndReportError( + 'Invalid from address', + SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + t('products.transactionRequest.wrongFrom') + ); + return; + } + + } catch { + deleteAndReportError( + 'Invalid from address', + SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + t('products.transactionRequest.invalidFrom') + ); + return; + } + } + + if (!isValidRequest) { + deleteAndReportError( + 'Bad request', + SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + t('products.transactionRequest.invalidRequest') + ); return; } - const { valid_until } = params; if (valid_until < getTimeSec()) { - deleteActiveRemoteRequest(request.from); - sendTonConnectResponse({ - response: { - error: { - code: SEND_TRANSACTION_ERROR_CODES.UNKNOWN_ERROR, - message: `Request timed out`, - }, - id: request.id.toString(), - }, - sessionCrypto, - clientSessionId: request.from - }) + deleteAndReportError( + 'Request expired', + SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + t('products.transactionRequest.expired') + ); return; } @@ -98,8 +152,8 @@ export function usePrepareConnectRequest(): (request: { from: string } & SendTra sessionCrypto, messages, app: connectedApp, - network: params.network, - from: params.from + network, + from } } } diff --git a/app/engine/hooks/holders/index.ts b/app/engine/hooks/holders/index.ts index 746c02d98..37fdad329 100644 --- a/app/engine/hooks/holders/index.ts +++ b/app/engine/hooks/holders/index.ts @@ -4,4 +4,5 @@ export { useHoldersAccountStatus } from './useHoldersAccountStatus'; export { useHoldersAccounts } from './useHoldersAccounts'; export { useHoldersEnroll } from './useHoldersEnroll'; export { useOfflineApp } from './useOfflineApp'; -export { useHoldersHiddenAccounts } from './useHoldersHiddenAccounts'; \ No newline at end of file +export { useHoldersHiddenAccounts } from './useHoldersHiddenAccounts'; +export { useHasHoldersProducts } from './useHasHoldersProducts'; \ No newline at end of file diff --git a/app/engine/hooks/holders/useHasHoldersProducts.ts b/app/engine/hooks/holders/useHasHoldersProducts.ts new file mode 100644 index 000000000..e4e312900 --- /dev/null +++ b/app/engine/hooks/holders/useHasHoldersProducts.ts @@ -0,0 +1,39 @@ +import { Address } from "@ton/core"; +import { useHoldersAccounts } from ".."; +import { Queries } from "../../queries"; +import { queryClient } from "../../clients"; +import { getQueryData } from "../../utils/getQueryData"; +import { HoldersAccountStatus } from "./useHoldersAccountStatus"; +import { HoldersAccountState } from "../../api/holders/fetchAccountState"; +import { HoldersAccounts } from "./useHoldersAccounts"; + +function hasAccounts(accs: HoldersAccounts | undefined) { + if (!accs) { + return false; + } + + const accounts = accs.accounts.length; + const cards = accs?.prepaidCards?.length || 0; + + return accounts + cards > 0; +} + +export function getHasHoldersProducts(address: string) { + const queryCache = queryClient.getQueryCache(); + const status = getQueryData(queryCache, Queries.Holders(address).Status()); + + const token = ( + !!status && + status.state !== HoldersAccountState.NoRef && + status.state !== HoldersAccountState.NeedEnrollment + ) ? status.token : null; + + const accounts = getQueryData(queryCache, Queries.Holders(address).Cards(!!token ? 'private' : 'public')); + + return hasAccounts(accounts); +} + +export function useHasHoldersProducts(address: string | Address) { + const accs = useHoldersAccounts(address).data; + return hasAccounts(accs); +} \ No newline at end of file diff --git a/app/engine/hooks/jettons/useHints.ts b/app/engine/hooks/jettons/useHints.ts index 5f8beadc0..a8b2d2353 100644 --- a/app/engine/hooks/jettons/useHints.ts +++ b/app/engine/hooks/jettons/useHints.ts @@ -1,12 +1,46 @@ import { useQuery } from '@tanstack/react-query'; import { Queries } from '../../queries'; import { fetchHints } from '../../api/fetchHints'; +import { z } from "zod"; +import { storagePersistence } from '../../../storage/storage'; + +const txsHintsKey = 'txsHints'; +const txsHintsCodec = z.array(z.string()); + +function getTxsHints(owner: string): string[] { + const hints = storagePersistence.getString(`${txsHintsKey}/${owner}`); + if (!hints) { + return []; + } + + const parsed = txsHintsCodec.safeParse(JSON.parse(hints)); + if (!parsed.success) { + return []; + } + + return parsed.data; +} + +export function addTxHints(owner: string, txHints: string[]) { + const hints = new Set([...getTxsHints(owner), ...txHints]); + storeTxsHints(owner, Array.from(hints)); +} + +function storeTxsHints(owner: string, hints: string[]) { + storagePersistence.set(`${txsHintsKey}/${owner}`, JSON.stringify(hints)); +} export function useHints(addressString?: string): string[] { let hints = useQuery({ queryKey: Queries.Hints(addressString || ''), queryFn: async () => { - return (await fetchHints(addressString!)).hints; + const fetched = (await fetchHints(addressString!)).hints; + + // merge with txs hints (to negate high hints worker lag) + const txsHints = getTxsHints(addressString || ''); + const hints = new Set([...fetched, ...txsHints]); + + return Array.from(hints); }, enabled: !!addressString, refetchInterval: 10000, diff --git a/app/engine/hooks/jettons/useSortedHints.ts b/app/engine/hooks/jettons/useSortedHints.ts index b59b190f5..051baec4d 100644 --- a/app/engine/hooks/jettons/useSortedHints.ts +++ b/app/engine/hooks/jettons/useSortedHints.ts @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { atomFamily, useRecoilState, useRecoilValue } from "recoil"; import { storagePersistence } from "../../../storage/storage"; import { z } from "zod"; @@ -31,10 +30,8 @@ function storeSortedHints(address: string, state: string[]) { export const sortedHintsAtomFamily = atomFamily({ key: 'wallet/hints/sorted/family', effects: (address) => [ - ({ setSelf, onSet }) => { - const stored = getSortedHints(address); - setSelf(stored); - + ({ onSet, setSelf }) => { + setSelf(getSortedHints(address)); onSet((newValue, _, isReset) => { if (isReset) { storeSortedHints(address, []); diff --git a/app/engine/hooks/jettons/useSortedHintsWatcher.ts b/app/engine/hooks/jettons/useSortedHintsWatcher.ts index 4ffe6dba7..0fd99bd27 100644 --- a/app/engine/hooks/jettons/useSortedHintsWatcher.ts +++ b/app/engine/hooks/jettons/useSortedHintsWatcher.ts @@ -27,40 +27,54 @@ function areArraysEqualByContent(a: T[], b: T[]): boolean { } function useSubToHintChange( - onChangeMany: (source?: string) => void, + reSortHints: () => void, owner: string, ) { useEffect(() => { const cache = queryClient.getQueryCache(); const unsub = cache.subscribe((e: QueryCacheNotifyEvent) => { + const queryKey = e.query.queryKey; if (e.type === 'updated') { - const queryKey = e.query.queryKey; + const action = e.action; + + // only care about success updates + if (action.type !== 'success') { + return; + } if (queryKey[0] === 'hints' && queryKey[1] === owner) { // check if the hint was added or removed const sorted = getSortedHints(owner); - const hints = getQueryData(cache, Queries.Hints(owner)); + const hints = action.data as string[] | undefined | null; // do not trigger if the hints are the same set if (areArraysEqualByContent(sorted, hints ?? [])) { return; } - onChangeMany(`${e.type} ${queryKey.join(',')}`); + reSortHints(); } else if ( - (queryKey[0] === 'hints' && queryKey[1] === owner) - || (queryKey[0] === 'contractMetadata') + (queryKey[0] === 'contractMetadata') || (queryKey[0] === 'account' && queryKey[2] === 'jettonWallet') - || (queryKey[0] === 'jettons' && queryKey[1] === 'swap') || (queryKey[0] === 'jettons' && queryKey[1] === 'master' && queryKey[3] === 'content') ) { - onChangeMany(`${e.type} ${queryKey.join(',')}`); + reSortHints(); + } else if ((queryKey[0] === 'jettons' && queryKey[1] === 'swap')) { + // check if the "price" changed so we can re-sort the hints + const newData = action.data as bigint | undefined | null; + const prev = getQueryData(cache, queryKey); + + if (newData === prev) { + return; + } + + reSortHints(); } } }); return unsub; - }, [owner, onChangeMany]); + }, [owner, reSortHints]); } export function useSortedHintsWatcher(address?: string) { @@ -68,13 +82,14 @@ export function useSortedHintsWatcher(address?: string) { const [, setSortedHints] = useSortedHintsState(address); const resyncAllHintsWeights = useCallback(throttle(() => { - const hints = getQueryData(queryClient.getQueryCache(), Queries.Hints(address ?? '')); + const cache = queryClient.getQueryCache(); + const hints = getQueryData(cache, Queries.Hints(address ?? '')); if (!hints) { return; } const sorted = hints - .map((h) => getHint(h, isTestnet)) + .map((h) => getHint(cache, h, isTestnet)) .sort(compareHints).filter(filterHint([])).map((x) => x.address); setSortedHints(sorted); diff --git a/app/engine/hooks/spam/useDontShowComments.ts b/app/engine/hooks/spam/useDontShowComments.ts index 09595c47b..c48fec026 100644 --- a/app/engine/hooks/spam/useDontShowComments.ts +++ b/app/engine/hooks/spam/useDontShowComments.ts @@ -1,9 +1,6 @@ -import { useRecoilValue, useSetRecoilState } from "recoil"; +import { useRecoilState } from "recoil"; import { dontShowCommentsState } from "../../state/spam"; -export function useDontShowComments(): [boolean, (value: boolean) => void] { - const value = useRecoilValue(dontShowCommentsState); - const update = useSetRecoilState(dontShowCommentsState); - - return [value, update]; +export function useDontShowComments(): [boolean, (valOrUpdater: ((currVal: boolean) => boolean) | boolean) => void] { + return useRecoilState(dontShowCommentsState); } \ No newline at end of file diff --git a/app/engine/hooks/transactions/useRawAccountTransactions.ts b/app/engine/hooks/transactions/useRawAccountTransactions.ts index bbf1b2684..04d090ef2 100644 --- a/app/engine/hooks/transactions/useRawAccountTransactions.ts +++ b/app/engine/hooks/transactions/useRawAccountTransactions.ts @@ -11,6 +11,7 @@ import { useClient4, useNetwork } from '..'; import { getLastBlock } from '../../accountWatcher'; import { log } from '../../../utils/log'; import { useEffect } from 'react'; +import { addTxHints } from '../jettons/useHints'; function externalAddressToStored(address?: ExternalAddress | null) { if (!address) { @@ -240,6 +241,21 @@ export function useRawAccountTransactions(account: string, options: { refetchOnM let txs = await fetchAccountTransactions(accountAddr, isTestnet, { lt, hash }); + // Add jetton wallets to hints (in case of hits worker lag being to high) + const txHints = txs + .filter(tx => { + const isIn = tx.parsed.kind === 'in'; + const isJetton = tx.operation.items.length > 0 + ? tx.operation.items[0].kind === 'token' + : false; + + return isIn && isJetton; + }) + .map(tx => tx.parsed.mentioned) + .flat(); + + addTxHints(account, txHints); + if (sliceFirst) { txs = txs.slice(1); } diff --git a/app/engine/state/lockAppWithAuthState.ts b/app/engine/state/lockAppWithAuthState.ts index 4e2fd82a3..740001e00 100644 --- a/app/engine/state/lockAppWithAuthState.ts +++ b/app/engine/state/lockAppWithAuthState.ts @@ -1,7 +1,8 @@ import { atom } from "recoil"; -import { sharedStoragePersistence } from "../../storage/storage"; +import { sharedStoragePersistence, storage } from "../../storage/storage"; const lockAppWithAuthStateKey = 'lockAppWithAuthState'; +const lockAppWithAuthMandatoryKey = 'lockAppWithAuthMandatory'; export function getLockAppWithAuthState() { return sharedStoragePersistence.getBoolean(lockAppWithAuthStateKey) || false; diff --git a/app/engine/state/spam.ts b/app/engine/state/spam.ts index cb9986f04..ef3688a0a 100644 --- a/app/engine/state/spam.ts +++ b/app/engine/state/spam.ts @@ -1,11 +1,11 @@ import { atom } from "recoil"; -import { storagePersistence } from "../../storage/storage"; +import { storage, storagePersistence } from "../../storage/storage"; import { toNano } from "@ton/core"; const minAmountKey = 'spamMinAmount'; function getMinAmountState(): bigint { - const stored = storagePersistence.getString(minAmountKey); + const stored = storagePersistence.getString(minAmountKey); if (!!stored) { try { return BigInt(stored); @@ -33,17 +33,30 @@ export const minAmountState = atom({ const dontShowCommentsKey = 'dontShowComments'; function getDontShowCommentsState(): boolean { - const stored = storagePersistence.getBoolean(dontShowCommentsKey); + const stored = storagePersistence.getBoolean(dontShowCommentsKey); if (!!stored) { return stored; } - return false; + return true; } function storeDontShowCommentsState(value: boolean) { storagePersistence.set(dontShowCommentsKey, value); } + +// 2.3.8 Migration to not show spam comments by default +const migrationKey = '2.3.8spamComments'; + +export function migrateDontShowComments() { + const migrated = storage.getBoolean(migrationKey); + + if (!migrated) { + storagePersistence.set(dontShowCommentsKey, true); + storage.set(migrationKey, true); + } +} + export const dontShowCommentsState = atom({ key: 'spam/dontShowComments', default: getDontShowCommentsState(), diff --git a/app/engine/state/tonconnect.ts b/app/engine/state/tonconnect.ts index 7785a298c..9d2eaadae 100644 --- a/app/engine/state/tonconnect.ts +++ b/app/engine/state/tonconnect.ts @@ -89,7 +89,7 @@ function storeConnectionsState(address: string, state: { [key: string]: Connecte export type ConnectionsMap = { [appKey: string]: ConnectedAppConnection[] } export type FullConnectionsMap = { [address: string]: ConnectionsMap } -function getFullConnectionsMap() { +export function getFullConnectionsMap() { let res: FullConnectionsMap = {}; const appState = getAppState(); @@ -210,7 +210,7 @@ export const pendingRequestsSelector = selector({ const connectExtensionsKey = 'tonconnect.extensions'; -function getStoredConnectExtensions(address?: string) { +export function getStoredConnectExtensions(address?: string): ConnectedAppsMap { if (!address) { return {}; } diff --git a/app/engine/tonconnect/types.ts b/app/engine/tonconnect/types.ts index 8c53d6e7b..c8ccbd72d 100644 --- a/app/engine/tonconnect/types.ts +++ b/app/engine/tonconnect/types.ts @@ -20,6 +20,13 @@ export interface ConnectQrQuery { ret: ReturnStrategy; } +export interface ConnectPushQuery { + validUntil: number; + from: string; + to: string; + message: string; +} + export type ReturnStrategy = 'back' | 'none' | string; export interface SignRawMessage { diff --git a/app/engine/tonconnectWatcher.ts b/app/engine/tonconnectWatcher.ts index fdfaa219a..5aaacc13f 100644 --- a/app/engine/tonconnectWatcher.ts +++ b/app/engine/tonconnectWatcher.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import EventSource, { MessageEvent } from 'react-native-sse'; import { createLogger, warn } from '../utils/log'; import { SessionCrypto } from '@tonconnect/protocol'; @@ -20,10 +20,13 @@ export function useTonconnectWatcher() { }, [] as ConnectedAppConnection[]).filter((item) => item.type === TonConnectBridgeType.Remote) as ConnectedAppConnectionRemote[]; }, [connectionsMap]); - const handleMessage = useHandleMessage( - connections.filter((item) => item.type === TonConnectBridgeType.Remote) as ConnectedAppConnectionRemote[], - logger - ); + const handleMessage = useHandleMessage(connections, logger); + + const handleMessageRef = useRef(handleMessage); + + useEffect(() => { + handleMessageRef.current = handleMessage; + }, [handleMessage]); useEffect(() => { if (connections.length === 0) { @@ -39,12 +42,10 @@ export function useTonconnectWatcher() { } let watcher: EventSource | null = new EventSource(url); - watcher.addEventListener( - 'message', - (event) => { - handleMessage(event as MessageEvent); - } - ); + watcher.addEventListener('message', (event) => { + logger.log('new event: ' + event.type); + handleMessageRef.current(event as MessageEvent); + }); watcher.addEventListener('open', () => { logger.log('sse connect: opened'); @@ -71,5 +72,5 @@ export function useTonconnectWatcher() { logger.log('sse close'); } }; - }, [handleMessage, connections, session]); + }, [connections, session]); } \ No newline at end of file diff --git a/app/fragments/AppAuthFragment.tsx b/app/fragments/AppAuthFragment.tsx new file mode 100644 index 000000000..a5e90a5bc --- /dev/null +++ b/app/fragments/AppAuthFragment.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useEffect, useState } from "react" +import { fragment } from "../fragment"; +import { useTypedNavigation } from "../utils/useTypedNavigation"; +import { resolveOnboarding } from "./resolveOnboarding"; +import { t } from "../i18n/t"; +import { useNetwork, useTheme } from "../engine/hooks"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { PasscodeInput } from "../components/passcode/PasscodeInput"; +import { storage } from "../storage/storage"; +import { useActionSheet } from "@expo/react-native-action-sheet"; +import { getCurrentAddress } from "../storage/appState"; +import { SecureAuthenticationCancelledError, loadWalletKeys } from "../storage/walletKeys"; +import { BiometricsState, getBiometricsState, passcodeLengthKey } from "../storage/secureStorage"; +import { Alert, AppState, NativeEventSubscription, Platform, View } from "react-native"; +import { useLogoutAndReset } from "../engine/hooks/accounts/useLogoutAndReset"; +import { useRoute } from "@react-navigation/native"; +import { updateLastAuthTimestamp } from "../components/secure/AuthWalletKeys"; +import { useAppBlur } from "../components/AppBlurContext"; + +export const AppAuthFragment = fragment(() => { + const navigation = useTypedNavigation(); + const theme = useTheme(); + const network = useNetwork(); + const safeAreaInsets = useSafeAreaInsets(); + const { showActionSheetWithOptions } = useActionSheet(); + const biometricsState = getBiometricsState(); + const useBiometrics = (biometricsState === BiometricsState.InUse); + const logOutAndReset = useLogoutAndReset(); + const isAppStart = useRoute().name !== 'AppAuth'; + const { blur, setBlur, setAuthInProgress } = useAppBlur(); + + const [attempts, setAttempts] = useState(0); + + const fullResetActionSheet = useCallback(() => { + const options = [t('common.cancel'), t('deleteAccount.logOutAndDelete')]; + const destructiveButtonIndex = 1; + const cancelButtonIndex = 0; + + showActionSheetWithOptions({ + title: t('confirm.logout.title'), + message: t('confirm.logout.message'), + options, + destructiveButtonIndex, + cancelButtonIndex, + }, (selectedIndex?: number) => { + switch (selectedIndex) { + case 1: + logOutAndReset(true); + navigation.navigateAndReplaceAll('Welcome'); + break; + case cancelButtonIndex: + // Canceled + default: + break; + } + }); + }, [logOutAndReset]); + + const onConfirmed = useCallback(() => { + updateLastAuthTimestamp(); + + // just in case + setBlur(false); + + if (!isAppStart) { + navigation.goBack(); + return; + } + const route = resolveOnboarding(network.isTestnet, false); + navigation.navigateAndReplaceAll(route); + }, []); + + useEffect(() => { + if (isAppStart) { + return; + } + + // lock native android navigation + + + const subscription: NativeEventSubscription = AppState.addEventListener('change', (newState) => { + if (newState === 'active') { + setBlur(false); + } + }); + + const transitionEndListener = navigation.base.addListener('transitionEnd', (e: any) => { + // hide blur on screen enter animation end + if (!e.data.closing) { + const current = AppState.currentState; + + if (current === 'active') { + setBlur(false); + } + } + }); + + return () => { + // Don't forget to remove the listener when the component is unmounted + transitionEndListener.remove(); + subscription.remove(); + }; + }, []); + + const authenticateWithBiometrics = useCallback(async () => { + if (!useBiometrics) { + return; + } + try { + setAuthInProgress(true); + const acc = getCurrentAddress(); + await loadWalletKeys(acc.secretKeyEnc); + onConfirmed(); + } catch (e) { + if (e instanceof SecureAuthenticationCancelledError) { + Alert.alert( + t('security.auth.canceled.title'), + t('security.auth.canceled.message'), + [{ text: t('common.ok') }] + ); + } + } finally { + setAuthInProgress(false); + } + }, [useBiometrics]); + + return ( + + {(blur && !isAppStart) ? ( + null + ) : ( + { + setAuthInProgress?.(true); + const acc = getCurrentAddress(); + if (!pass) { + return; + } + try { + await loadWalletKeys(acc.secretKeyEnc, pass); + onConfirmed(); + } catch (e) { + setAuthInProgress?.(false); + setAttempts(attempts + 1); + throw Error('Failed to load keys'); + } + }} + onMount={useBiometrics ? authenticateWithBiometrics : undefined} + onLogoutAndReset={ + (attempts > 0 + && attempts % 5 === 0 + ) + ? fullResetActionSheet + : undefined + } + passcodeLength={storage.getNumber(passcodeLengthKey) ?? 6} + onRetryBiometrics={useBiometrics ? authenticateWithBiometrics : undefined} + /> + )} + + ); +}); \ No newline at end of file diff --git a/app/fragments/AppStartAuthFragment.tsx b/app/fragments/AppStartAuthFragment.tsx deleted file mode 100644 index 5f1601d42..000000000 --- a/app/fragments/AppStartAuthFragment.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useCallback, useState } from "react" -import { fragment } from "../fragment"; -import { useTypedNavigation } from "../utils/useTypedNavigation"; -import { resolveOnboarding } from "./resolveOnboarding"; -import { t } from "../i18n/t"; -import { useNetwork, useTheme } from "../engine/hooks"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { PasscodeInput } from "../components/passcode/PasscodeInput"; -import { storage } from "../storage/storage"; -import { useActionSheet } from "@expo/react-native-action-sheet"; -import { getCurrentAddress } from "../storage/appState"; -import { SecureAuthenticationCancelledError, loadWalletKeys } from "../storage/walletKeys"; -import { BiometricsState, getBiometricsState, passcodeLengthKey } from "../storage/secureStorage"; -import { Alert, View } from "react-native"; -import { warn } from "../utils/log"; -import { useLogoutAndReset } from "../engine/hooks/accounts/useLogoutAndReset"; - -export const AppStartAuthFragment = fragment(() => { - const navigation = useTypedNavigation(); - const theme = useTheme(); - const network = useNetwork(); - const safeAreaInsets = useSafeAreaInsets(); - const { showActionSheetWithOptions } = useActionSheet(); - const biometricsState = getBiometricsState(); - const useBiometrics = (biometricsState === BiometricsState.InUse); - const logOutAndReset = useLogoutAndReset(); - - const [attempts, setAttempts] = useState(0); - - const fullResetActionSheet = useCallback(() => { - const options = [t('common.cancel'), t('deleteAccount.logOutAndDelete')]; - const destructiveButtonIndex = 1; - const cancelButtonIndex = 0; - - showActionSheetWithOptions({ - title: t('confirm.logout.title'), - message: t('confirm.logout.message'), - options, - destructiveButtonIndex, - cancelButtonIndex, - }, (selectedIndex?: number) => { - switch (selectedIndex) { - case 1: - logOutAndReset(true); - navigation.navigateAndReplaceAll('Welcome'); - break; - case cancelButtonIndex: - // Canceled - default: - break; - } - }); - }, [logOutAndReset]); - - const onConfirmed = useCallback(() => { - const route = resolveOnboarding(network.isTestnet, false); - navigation.navigateAndReplaceAll(route); - }, []); - - return ( - - { - const acc = getCurrentAddress(); - if (!pass) { - return; - } - try { - await loadWalletKeys(acc.secretKeyEnc, pass); - onConfirmed(); - } catch (e) { - setAttempts(attempts + 1); - throw Error('Failed to load keys'); - } - }} - onMount={useBiometrics - ? async () => { - try { - const acc = getCurrentAddress(); - await loadWalletKeys(acc.secretKeyEnc); - onConfirmed(); - } catch (e) { - if (e instanceof SecureAuthenticationCancelledError) { - Alert.alert( - t('security.auth.canceled.title'), - t('security.auth.canceled.message'), - [{ text: t('common.ok') }] - ); - } - } - } - : undefined} - onLogoutAndReset={ - (attempts > 0 - && attempts % 5 === 0 - ) - ? fullResetActionSheet - : undefined - } - passcodeLength={storage.getNumber(passcodeLengthKey) ?? 6} - onRetryBiometrics={ - (useBiometrics) - ? async () => { - try { - const acc = getCurrentAddress(); - await loadWalletKeys(acc.secretKeyEnc); - onConfirmed() - } catch (e) { - if (e instanceof SecureAuthenticationCancelledError) { - Alert.alert( - t('security.auth.canceled.title'), - t('security.auth.canceled.message'), - [{ text: t('common.ok') }] - ); - } else { - Alert.alert(t('secure.onBiometricsError')); - warn('Failed to load wallet keys'); - } - } - } - : undefined - } - /> - - ); -}); \ No newline at end of file diff --git a/app/fragments/HomeFragment.tsx b/app/fragments/HomeFragment.tsx index 6031b1b9b..5ab498a00 100644 --- a/app/fragments/HomeFragment.tsx +++ b/app/fragments/HomeFragment.tsx @@ -29,6 +29,7 @@ import { Typography } from '../components/styles'; import { TransactionDescription } from '../engine/types'; import { useParams } from '../utils/useParams'; import { TonConnectAuthType } from './secure/dapps/TonConnectAuthenticateFragment'; +import { TransferFragmentProps } from './secure/TransferFragment'; const Tab = createBottomTabNavigator(); @@ -36,6 +37,9 @@ export type HomeFragmentProps = { navigateTo?: { type: 'tx', transaction: TransactionDescription + } | { + type: 'tonconnect-request', + request: TransferFragmentProps } }; @@ -162,6 +166,8 @@ export const HomeFragment = fragment(() => { useEffect(() => { if (navigateTo?.type === 'tx') { navigation.navigate('Transaction', { transaction: navigateTo.transaction }); + } else if (navigateTo?.type === 'tonconnect-request') { + navigation.navigateTransfer(navigateTo.request); } }, []); diff --git a/app/fragments/LogoutFragment.tsx b/app/fragments/LogoutFragment.tsx index 28eff747f..088352a49 100644 --- a/app/fragments/LogoutFragment.tsx +++ b/app/fragments/LogoutFragment.tsx @@ -12,10 +12,20 @@ import { openWithInApp } from "../utils/openWithInApp"; import { useTheme } from "../engine/hooks"; import { useDeleteCurrentAccount } from "../engine/hooks/appstate/useDeleteCurrentAccount"; import { StatusBar } from "expo-status-bar"; +import { getAppState } from "../storage/appState"; +import { getHasHoldersProducts } from "../engine/hooks/holders/useHasHoldersProducts"; import IcLogout from '@assets/ic-alert-red.svg'; import Support from '@assets/ic-support.svg'; +function hasHoldersProductsOnDevice(isTestnet: boolean) { + const appState = getAppState(); + + return !!appState.addresses.find((acc) => { + return getHasHoldersProducts(acc.address.toString({ testOnly: isTestnet })); + }); +} + export const LogoutFragment = fragment(() => { const theme = useTheme(); const safeArea = useSafeAreaInsets(); @@ -48,10 +58,6 @@ export const LogoutFragment = fragment(() => { const [isShown, setIsShown] = useState(false); - const onLogout = useCallback(async () => { - onAccountDeleted(); - }, [onAccountDeleted]); - const showLogoutActSheet = useCallback(() => { if (isShown) { return; @@ -71,7 +77,7 @@ export const LogoutFragment = fragment(() => { }, (selectedIndex?: number) => { switch (selectedIndex) { case 1: - onLogout(); + onAccountDeleted(); break; case cancelButtonIndex: // Canceled @@ -80,7 +86,7 @@ export const LogoutFragment = fragment(() => { } setIsShown(false); }); - }, [isShown, onLogout]); + }, [isShown, onAccountDeleted]); return ( { const navigation = useTypedNavigation(); const authContext = useKeysAuth(); const theme = useTheme(); + const { isTestnet } = useNetwork(); const passcodeState = usePasscodeState(); const biometricsState = useBiometricsState(); const setBiometricsState = useSetBiometricsState(); + const selectedAddress = useSelectedAccount()!.address.toString({ testOnly: isTestnet }); + const selectedAccountIndex = useAppState().selected; + const accHasHoldersProducts = useHasHoldersProducts(selectedAddress); const [deviceEncryption, setDeviceEncryption] = useState(); const [lockAppWithAuthState, setLockAppWithAuthState] = useLockAppWithAuthState(); + // Check if any of the accounts has holders products + const deviceHasHoldersProducts = useMemo(() => { + const appState = getAppState(); + + return appState.addresses.some(acc => getHasHoldersProducts(acc.address.toString({ testOnly: isTestnet }))); + }, [selectedAccountIndex, selectedAddress, accHasHoldersProducts, isTestnet]); + const biometricsProps = useMemo(() => { if (passcodeState !== PasscodeState.Set) { return null; @@ -204,6 +218,13 @@ export const SecurityFragment = fragment(() => { )} )} + + { }} /> + {deviceHasHoldersProducts && ( + + {t('mandatoryAuth.settingsDescription')} + + )} ) diff --git a/app/fragments/apps/components/inject/createInjectSource.ts b/app/fragments/apps/components/inject/createInjectSource.ts index a6e98254d..e1e314ba1 100644 --- a/app/fragments/apps/components/inject/createInjectSource.ts +++ b/app/fragments/apps/components/inject/createInjectSource.ts @@ -144,7 +144,7 @@ export const statusBarAPI = (safeArea: EdgeInsets) => { ` } -export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean }) => { +export const authAPI = (params: { lastAuthTime?: number, isSecured: boolean }) => { return ` window['tonhub-auth'] = (() => { let __AUTH_AVAILIBLE = true; @@ -157,21 +157,32 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean callback({ erorr: 'auth.inProgress' }); return; } - window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.getLastAuthTime' } })); currentCallback = callback; + window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.getLastAuthTime' } })); } - const authenicate = (callback) => { + const authenticate = (callback) => { if (inProgress) { - callback({ authenicated: false, erorr: 'auth.inProgress' }); + callback({ isAuthenticated: false, erorr: 'auth.inProgress' }); return; } - window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.authenticate' } })); inProgress = true; currentCallback = callback; + window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.authenticate' } })); }; + const lockAppWithAuth = (callback) => { + if (inProgress) { + callback({ isAuthenticated: false, erorr: 'auth.inProgress' }); + return; + } + + inProgress = true; + currentCallback = callback; + window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.lockAppWithAuth' } })); + } + const __response = (ev) => { inProgress = false; if (!ev || !ev.data) { @@ -186,16 +197,21 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean params.lastAuthTime = ev.data; currentCallback(ev.data); } else { - if (!!ev.data.lastAuthTime && ev.data.authenicated === true) { + if (!!ev.data.lastAuthTime) { params.lastAuthTime = ev.data.lastAuthTime; } - currentCallback({ authenicated: ev.data.authenicated }); + if (typeof ev.data.isSecured === 'boolean') { + params.isSecured = true; + currentCallback({ isSecured: ev.data.isSecured, lastAuthTime: ev.data.lastAuthTime }); + } else if (typeof ev.data.isAuthenticated === 'boolean') { + currentCallback({ isAuthenticated: ev.data.isAuthenticated, lastAuthTime: ev.data.lastAuthTime }); + } } currentCallback = null; } } - const obj = { __AUTH_AVAILIBLE, params, authenicate, getLastAuthTime, __response }; + const obj = { __AUTH_AVAILIBLE, params, authenticate, getLastAuthTime, lockAppWithAuth, __response }; Object.freeze(obj); return obj; })(); @@ -311,7 +327,12 @@ export function dispatchLastAuthTimeResponse(webRef: React.RefObject, l webRef.current?.injectJavaScript(injectedMessage); } -export function dispatchAuthResponse(webRef: React.RefObject, data: { authenicated: boolean, lastAuthTime?: number }) { +export function dispatchAuthResponse(webRef: React.RefObject, data: { isAuthenticated: boolean, lastAuthTime?: number }) { + let injectedMessage = `window['tonhub-auth'].__response(${JSON.stringify({ data })}); true;`; + webRef.current?.injectJavaScript(injectedMessage); +} + +export function dispatchLockAppWithAuthResponse(webRef: React.RefObject, data: { isSecured: boolean, lastAuthTime?: number }) { let injectedMessage = `window['tonhub-auth'].__response(${JSON.stringify({ data })}); true;`; webRef.current?.injectJavaScript(injectedMessage); } diff --git a/app/fragments/apps/components/review/ReportComponent.tsx b/app/fragments/apps/components/review/ReportComponent.tsx index 09d65fcbf..d916c02bb 100644 --- a/app/fragments/apps/components/review/ReportComponent.tsx +++ b/app/fragments/apps/components/review/ReportComponent.tsx @@ -49,7 +49,7 @@ export const ReportComponent = memo(({ url }: { url: string }) => { > { > { style={{ paddingLeft: 16 }} onClosePressed={navigation.goBack} /> - { hashColor /> - { blurOnSubmit={true} editable={true} onFocus={() => onFocus(1)} + cursorColor={theme.accent} /> { editable={true} multiline onFocus={() => onFocus(0)} + cursorColor={theme.accent} /> {address.length >= 48 && !parsed && ( @@ -284,38 +285,19 @@ export const ContactNewFragment = fragment(() => { )} - - {Platform.OS === 'ios' ? ( - - - - ) : ( - - - - )} + + + + ); }); \ No newline at end of file diff --git a/app/fragments/contacts/ContactsFragment.tsx b/app/fragments/contacts/ContactsFragment.tsx index 2c83a2ce8..8e9c81b6b 100644 --- a/app/fragments/contacts/ContactsFragment.tsx +++ b/app/fragments/contacts/ContactsFragment.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from "react"; -import { Platform, View, Text, ScrollView, Image } from "react-native"; +import { Platform, View, Text, Image, FlatList } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Address } from "@ton/core"; import { ContactItemView } from "../../components/Contacts/ContactItemView"; @@ -16,6 +16,7 @@ import { useAddressBookContext } from "../../engine/AddressBookContext"; import { useDimensions } from "@react-native-community/hooks"; import { ATextInput } from "../../components/ATextInput"; import { KnownWallets } from "../../secure/KnownWallets"; +import { Typography } from "../../components/styles"; const EmptyIllustrations = { dark: require('@assets/empty-contacts-dark.webp'), @@ -100,11 +101,13 @@ export const ContactsFragment = fragment(() => { } return ( - + { title={t('contacts.title')} onClosePressed={navigation.goBack} /> - + {contactsEntries.length > 0 && + + } )} - - {contactsEntries.length === 0 ? ( - <> + item[0]} + renderItem={({ item }) => ( + + )} + style={{ flex: 1, flexShrink: 1 }} + contentContainerStyle={{ paddingHorizontal: 16 }} + ListEmptyComponent={ + { /> - {t('contacts.empty')} - + }, Typography.regular17_24]}> {t('contacts.description')} - {transactionsAddresses.map((a, index) => { return ( @@ -187,46 +199,26 @@ export const ContactsFragment = fragment(() => { /> ); })} - - ) : ( - <> - {contactsSearchList.map((d) => { - return ( - - ); - })} - {contactsSearchList.length === 0 && ( - - - {t('contacts.empty')} - - - )} - - )} - + + } + /> {!callback && ( - 0) ? 16 + 56 + safeArea.bottom : 0 } }) - ]} - /> + 0 + ? (safeArea.bottom || 56) + 56 + 16 + : 16, + padding: 16 + } + }), + ]}> + + )} ); diff --git a/app/fragments/onboarding/WalletCreateFragment.tsx b/app/fragments/onboarding/WalletCreateFragment.tsx index 279b064f1..abdf941dd 100644 --- a/app/fragments/onboarding/WalletCreateFragment.tsx +++ b/app/fragments/onboarding/WalletCreateFragment.tsx @@ -59,8 +59,8 @@ export const WalletCreateFragment = systemFragment(() => { return ( @@ -93,12 +93,12 @@ export const WalletCreateFragment = systemFragment(() => { navigation.goBack(); } }} - style={[{ paddingLeft: 16, paddingTop: safeArea.top }, Platform.select({ ios: { paddingTop: 32 } })]} + style={[{ paddingTop: safeArea.top }, Platform.select({ ios: { paddingTop: 32 } })]} /> { /> )} - + { setState({ ...state, saved: true }); }} diff --git a/app/fragments/secure/MandatoryAuthSetupFragment.tsx b/app/fragments/secure/MandatoryAuthSetupFragment.tsx new file mode 100644 index 000000000..27e19f20a --- /dev/null +++ b/app/fragments/secure/MandatoryAuthSetupFragment.tsx @@ -0,0 +1,147 @@ +import { Platform, View, Text, Image } from "react-native"; +import { fragment } from "../../fragment"; +import { AuthRejectReason, useKeysAuth } from "../../components/secure/AuthWalletKeys"; +import { StatusBar } from "expo-status-bar"; +import { useTheme } from "../../engine/hooks"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { t } from "../../i18n/t"; +import { useTypedNavigation } from "../../utils/useTypedNavigation"; +import { useParams } from "../../utils/useParams"; +import { Typography } from "../../components/styles"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useDimensions } from "@react-native-community/hooks"; +import { CheckBox } from "../../components/CheckBox"; +import { useCallback, useState } from "react"; +import { ScrollView } from "react-native-gesture-handler"; +import { RoundButton } from "../../components/RoundButton"; +import { useLockAppWithAuthState } from "../../engine/hooks/settings"; +import { useToaster } from "../../components/toast/ToastProvider"; + +import WarningIcon from '@assets/ic-warning-banner.svg'; + +export type MandatoryAuthSetupParams = { callback?: (ok: boolean) => void }; + +export const MandatoryAuthSetupFragment = fragment(() => { + const dimensions = useDimensions(); + const authContext = useKeysAuth(); + const safeArea = useSafeAreaInsets(); + const navigation = useTypedNavigation(); + const toaster = useToaster(); + const theme = useTheme(); + const { callback } = useParams(); + const [secured, setSecured] = useState(false); + const [lockAppWithAuth, setLockAppWithAuth] = useLockAppWithAuthState(); + + const onCallback = (ok: boolean) => { + navigation.goBack(); + callback?.(ok); + }; + + const turnAuthOn = useCallback(async () => { + try { + // authenticate and switch mandatory auth on + if (!lockAppWithAuth) { + await authContext.authenticate({ cancelable: true, backgroundColor: theme.elevation }); + setLockAppWithAuth(true); + } + + onCallback(true); + + } catch (reason) { + if (reason === AuthRejectReason.Canceled) { + toaster.show({ message: t('security.auth.canceled.title'), type: 'default' }); + } else if (typeof reason !== 'string') { + toaster.show({ message: t('products.tonConnect.errors.unknown'), type: 'default' }); + } + } + }, [lockAppWithAuth]); + + return ( + + + onCallback(false)} + style={[Platform.select({ android: { marginTop: safeArea.top } }), { paddingHorizontal: 16 }]} + /> + + + + {t('mandatoryAuth.title')} + + + {t('mandatoryAuth.description')} + + + + + + {t('mandatoryAuth.alert')} + + + + + + + + + {t('mandatoryAuth.confirmDescription')} + + } + style={{ marginTop: 16 }} + /> + + + + navigation.navigate('WalletBackup', { back: true })} + /> + + + ); +}) \ No newline at end of file diff --git a/app/fragments/secure/components/TransferBatch.tsx b/app/fragments/secure/components/TransferBatch.tsx index c45dfb66c..9de437e1c 100644 --- a/app/fragments/secure/components/TransferBatch.tsx +++ b/app/fragments/secure/components/TransferBatch.tsx @@ -428,7 +428,7 @@ export const TransferBatch = memo((props: Props) => { }} /> @@ -470,7 +470,7 @@ export const TransferBatch = memo((props: Props) => { }} /> { src={value[1].jettonMaster.image?.preview256} blurhash={value[1].jettonMaster.image?.blurhash} width={48} - heigh={48} + height={48} borderRadius={24} /> diff --git a/app/fragments/secure/components/TransferSingleView.tsx b/app/fragments/secure/components/TransferSingleView.tsx index b43f284e8..b72dc4cc0 100644 --- a/app/fragments/secure/components/TransferSingleView.tsx +++ b/app/fragments/secure/components/TransferSingleView.tsx @@ -62,7 +62,13 @@ const TxAvatar = memo(({ }) => { if (forceAvatar) { - return (); + return ( + + ); } return ( diff --git a/app/fragments/secure/dapps/AuthenticateFragment.tsx b/app/fragments/secure/dapps/AuthenticateFragment.tsx index 7966f8ad1..352c19475 100644 --- a/app/fragments/secure/dapps/AuthenticateFragment.tsx +++ b/app/fragments/secure/dapps/AuthenticateFragment.tsx @@ -165,9 +165,6 @@ const SignStateLoader = memo((props: { session: string, endpoint: string }) => { removePendingGrant(props.session); }); - // Track - trackEvent(MixpanelEvent.Connect, { url, name }, isTestnet); - // Exit if already exited screen if (!active.current) { return; @@ -237,9 +234,6 @@ const SignStateLoader = memo((props: { session: string, endpoint: string }) => { customImage ? customImage : image ); - // Track installation - trackEvent(MixpanelEvent.AppInstall, { url: endpoint, domain: domain }, isTestnet); - // Navigate navigation.replace('App', { url }); } else { diff --git a/app/fragments/secure/dapps/DappAuthComponent.tsx b/app/fragments/secure/dapps/DappAuthComponent.tsx index 3fc2202ec..aae7769b5 100644 --- a/app/fragments/secure/dapps/DappAuthComponent.tsx +++ b/app/fragments/secure/dapps/DappAuthComponent.tsx @@ -218,7 +218,7 @@ export const DappAuthComponent = memo(({ flexDirection: 'row', }}> - + { // Track installation success.current = true; - trackEvent(MixpanelEvent.AppInstall, { url: props.url, domain: domain }, isTestnet); // Navigate navigation.replace('App', { url: props.url }); }, [useCreateDomainKeyIfNeeded]); - useEffect(() => { - if (!success.current) { - let domain = extractDomain(props.url); - trackEvent(MixpanelEvent.AppInstallCancel, { url: props.url, domain: domain }, isTestnet); - } - }, []); - return ( { @@ -58,9 +55,11 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr const toaster = useToaster(); const [state, setState] = useState({ type: 'loading' }); const saveAppConnection = useSaveAppConnection(); + const toastMargin = safeArea.bottom + 56 + 48; useEffect(() => { (async () => { + // remote bridge if (connectProps.type === 'qr' || connectProps.type === 'link') { try { const handled = await handleConnectDeeplink(connectProps.query); @@ -96,6 +95,7 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr return; } + // continue with local injected bridge checkProtocolVersionCapability(connectProps.protocolVersion); verifyConnectRequest(connectProps.request); @@ -123,12 +123,63 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr })() }, []); + const timerRef = useRef(null); // Approve - let active = useRef(true); + const active = useRef(true); useEffect(() => { - return () => { active.current = false; }; + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + if (!active.current) { + return; + } + + active.current = false; + + // reject on cancel + if (connectProps.type === 'callback' && !!connectProps.callback) { + connectProps.callback({ ok: false }); + } + } }, []); + const navigate = useRef(() => { + active.current = false; + navigation.goBack(); + }); + useEffect(() => { + // default to go back + if (state.type !== 'initing' || connectProps.type === TonConnectAuthType.Callback) { + return; + } + + navigate.current = () => { + if (!active.current) { + return; + } + active.current = false; + + // close modal + navigation.goBack(); + + // resolve return strategy + if (!!state.returnStrategy) { + if (state.returnStrategy === 'back') { + Minimizer.goBack(); + } else if (state.returnStrategy !== 'none') { + try { + const url = new URL(decodeURIComponent(state.returnStrategy)); + Linking.openURL(url.toString()); + } catch (e) { + warn('Failed to open url'); + } + } + } + }; + + }, [connectProps.type, state.type]); + const approve = useCallback(async (selectedAccount?: SelectedAccount) => { if (state.type !== 'initing') { @@ -259,41 +310,25 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr type: 'success', message: t('products.tonConnect.successAuth'), onDestroy: () => { - if (connectProps.type === TonConnectAuthType.Link) { - setTimeout(() => { - if (!!state.returnStrategy) { - if (state.returnStrategy === 'back') { - Minimizer.goBack(); - } else if (state.returnStrategy !== 'none') { - try { - const url = new URL(decodeURIComponent(state.returnStrategy)); - Linking.openURL(url.toString()); - } catch (e) { - warn('Failed to open url'); - } - } - } - - navigation.goBack(); - }, 50); - return; - } - - navigation.goBack(); - } + navigate.current(); + }, + duration: ToastDuration.SHORT, + marginBottom: toastMargin }); return; - } else if (connectProps.type === 'callback') { + } else if (connectProps.type === TonConnectAuthType.Callback) { toaster.show({ type: 'success', message: t('products.tonConnect.successAuth'), onDestroy: () => { connectProps.callback({ ok: true, replyItems }); - setTimeout(() => { - navigation.goBack(); + timerRef.current = setTimeout(() => { + navigate.current(); }, 50); - } + }, + duration: ToastDuration.SHORT, + marginBottom: toastMargin }); return; } @@ -306,7 +341,8 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr // Show user error toast toaster.show({ type: 'error', - message: message + message: message, + marginBottom: toastMargin }); warn('Failed to approve'); @@ -314,30 +350,13 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr }, [state, saveAppConnection, toaster]); - const onCancel = useCallback(() => { - if (state.type === 'loading') { - navigation.goBack(); - return; - } - if (state.returnStrategy && state.returnStrategy !== 'none' && state.returnStrategy !== 'back') { - try { - const url = new URL(state.returnStrategy); - Linking.openURL(url.toString()); - return; - } catch (e) { - warn('Failed to open url'); - } - navigation.goBack(); - return; - } - navigation.goBack(); - }, [state]); - return ( { + navigate.current(); + }} single={connectProps.type === 'callback'} /> ) @@ -367,13 +386,5 @@ export type TonConnectAuthProps = { export const TonConnectAuthenticateFragment = fragment(() => { const props = useParams(); - useEffect(() => { - return () => { - if (props && props.type === 'callback' && props.callback) { - props.callback({ ok: false }); - } - } - }, []); - return (); }); \ No newline at end of file diff --git a/app/fragments/staking/StakingPoolSelectorFragment.tsx b/app/fragments/staking/StakingPoolSelectorFragment.tsx index 873b12b33..0eee06e55 100644 --- a/app/fragments/staking/StakingPoolSelectorFragment.tsx +++ b/app/fragments/staking/StakingPoolSelectorFragment.tsx @@ -75,7 +75,7 @@ const PoolItem = memo(({ selected, pool, onSelect }: { selected?: boolean, pool: )} diff --git a/app/fragments/wallet/AvatarPickerFragment.tsx b/app/fragments/wallet/AvatarPickerFragment.tsx index 6d01c73c8..3a7f11ef9 100644 --- a/app/fragments/wallet/AvatarPickerFragment.tsx +++ b/app/fragments/wallet/AvatarPickerFragment.tsx @@ -1,4 +1,4 @@ -import { Platform, Pressable, View, ScrollView, KeyboardAvoidingView, Text } from "react-native"; +import { Platform, Pressable, View, ScrollView, Text } from "react-native"; import { fragment } from "../../fragment"; import { useParams } from "../../utils/useParams"; import { ScreenHeader } from "../../components/ScreenHeader"; @@ -40,7 +40,9 @@ export const AvatarPickerFragment = fragment(() => { }, [hashState, selectedColor]); return ( - + { { })} - + - + ) }); \ No newline at end of file diff --git a/app/fragments/wallet/PendingTxPreviewFragment.tsx b/app/fragments/wallet/PendingTxPreviewFragment.tsx index d3571abfd..ff188fde3 100644 --- a/app/fragments/wallet/PendingTxPreviewFragment.tsx +++ b/app/fragments/wallet/PendingTxPreviewFragment.tsx @@ -229,7 +229,15 @@ const PendingTxPreview = () => { }}> {params.forceAvatar ? ( - + ) : ( { navigation.goBack(); if (needsEnrolment || !isHoldersReady) { - navigation.navigateHoldersLanding({ endpoint: holdersUrl, onEnrollType: { type: 'create' } }); + navigation.navigateHoldersLanding({ endpoint: holdersUrl, onEnrollType: { type: 'create' } }, network.isTestnet); return; } - navigation.navigateHolders({ type: 'create' }); - }, [needsEnrolment, isHoldersReady]); + navigation.navigateHolders({ type: 'create' }, network.isTestnet); + }, [needsEnrolment, isHoldersReady, network.isTestnet]); return ( diff --git a/app/fragments/wallet/ProductsListFragment.tsx b/app/fragments/wallet/ProductsListFragment.tsx index 7363befaa..d5729bb4a 100644 --- a/app/fragments/wallet/ProductsListFragment.tsx +++ b/app/fragments/wallet/ProductsListFragment.tsx @@ -60,6 +60,7 @@ const ProductsListComponent = memo(({ type, isLedger }: { type: 'holders-account holdersAccStatus={holdersAccStatus} itemStyle={{ backgroundColor: theme.surfaceOnElevation }} onBeforeOpen={navigation.goBack} + hideCardsIfEmpty /> ); }, @@ -113,7 +114,7 @@ export const ProductsListFragment = fragment(() => { return ( {type === 'jettons' ? ( - }> + }> ) : ( diff --git a/app/fragments/wallet/ReceiveFragment.tsx b/app/fragments/wallet/ReceiveFragment.tsx index 03d780eda..16883450e 100644 --- a/app/fragments/wallet/ReceiveFragment.tsx +++ b/app/fragments/wallet/ReceiveFragment.tsx @@ -159,7 +159,7 @@ export const ReceiveFragment = fragment(() => { src={jetton.data.image?.preview256} blurhash={jetton.data.image?.blurhash} width={46} - heigh={46} + height={46} borderRadius={23} lockLoading /> diff --git a/app/fragments/wallet/TransactionPreviewFragment.tsx b/app/fragments/wallet/TransactionPreviewFragment.tsx index 0fd7dff53..ba5a5a467 100644 --- a/app/fragments/wallet/TransactionPreviewFragment.tsx +++ b/app/fragments/wallet/TransactionPreviewFragment.tsx @@ -40,6 +40,7 @@ import { HoldersOp, HoldersOpView } from "../../components/transfer/HoldersOpVie import { previewToTransferParams } from "../../utils/toTransferParams"; import { useContractInfo } from "../../engine/hooks/metadata/useContractInfo"; import { ForcedAvatar, ForcedAvatarType } from "../../components/avatar/ForcedAvatar"; +import { isTxSPAM } from "../../utils/spam/isTxSPAM"; const TransactionPreview = () => { const theme = useTheme(); @@ -100,7 +101,6 @@ const TransactionPreview = () => { const avatarColor = avatarColors[avatarColorHash]; const contact = addressBook.asContact(opAddressBounceable); - const isSpam = addressBook.isDenyAddress(opAddressBounceable); let dateStr = `${formatDate(tx.base.time, 'MMMM dd, yyyy')} • ${formatTime(tx.base.time)}`; dateStr = dateStr.charAt(0).toUpperCase() + dateStr.slice(1); @@ -191,15 +191,18 @@ const TransactionPreview = () => { known = { name: opAddressWalletSettings.name } } + const verified = !!tx.verified; const config = useServerConfig().data; - const spam = config?.wallets?.spam?.includes(opAddressBounceable) - || isSpam - || ( - BigMath.abs(BigInt(tx.base.parsed.amount)) < spamMinAmount - && tx.base.parsed.body?.type === 'comment' - && !knownWallets[opAddressBounceable] - && !isTestnet - ) && tx.base.parsed.kind !== 'out'; + const spam = isTxSPAM( + tx, + { + knownWallets, + isDenyAddress: addressBook.isDenyAddress, + spamWallets: config?.wallets?.spam ?? [], + spamMinAmount, + isTestnet + } + ); const participants = useMemo(() => { const appState = getAppState(); @@ -260,8 +263,6 @@ const TransactionPreview = () => { master: jettonMaster }); - const verified = !!tx.verified || verifiedJetton; - return ( { }}> {!!forceAvatar ? ( - + ) : ( tx.base.outMessagesCount > 1 ? ( { backgroundColor={avatarColor} markContact={!!contact} icProps={{ - isOwn: isOwn, + isOwn, borderWidth: 2, position: 'bottom', size: 28 @@ -522,7 +531,7 @@ const TransactionPreview = () => { ) : ( <> - {!(dontShowComments && isSpam) && (!!operation.comment) && ( + {!(dontShowComments && spam) && (!!operation.comment) && ( diff --git a/app/fragments/wallet/products/ProductButton.tsx b/app/fragments/wallet/products/ProductButton.tsx index e4201ab90..686a86f01 100644 --- a/app/fragments/wallet/products/ProductButton.tsx +++ b/app/fragments/wallet/products/ProductButton.tsx @@ -51,7 +51,7 @@ export function ProductButton(props: ProductButtonProps) { requireSource={props.requireSource} blurhash={props.blurhash} width={46} - heigh={46} + height={46} borderRadius={props.extension ? 8 : 23} /> ) diff --git a/app/fragments/wallet/views/DappRequestButton.tsx b/app/fragments/wallet/views/DappRequestButton.tsx index dfbdff93c..ef5204eaa 100644 --- a/app/fragments/wallet/views/DappRequestButton.tsx +++ b/app/fragments/wallet/views/DappRequestButton.tsx @@ -22,7 +22,7 @@ export const DappRequestButton = memo((props: DappRequestButtonProps) => { { const navigation = useTypedNavigation(); const toaster = useToaster(); const bottomBarHeight = useBottomTabBarHeight(); const appBySessionId = useConnectAppByClientSessionId(); - const prepareConnectRequest = usePrepareConnectRequest(); + const toastProps = Platform.select({ + ios: { marginBottom: bottomBarHeight + 24 }, + android: { marginBottom: 16 }, + default: { marginBottom: 16 } + }); + const prepareConnectRequest = usePrepareConnectRequest({ isTestnet: props.isTestnet, toaster, toastProps }); const connectCallback = useConnectCallback(); const url = appBySessionId(props.request.from).connectedApp?.manifestUrl; const appManifest = useAppManifest(url ?? ''); @@ -84,25 +39,30 @@ export const TonConnectRequestButton = memo((props: TonConnectRequestButtonProps const image = appManifest?.iconUrl; - const onPress = useCallback(() => { + const onPress = useCallback(async () => { const request = props.request; const prepared = prepareConnectRequest(request); - if (request.method === 'sendTransaction' && prepared) { - const isValid = checkNetworkAndFrom( - { request: prepared, isTestnet: props.isTestnet }, - toaster, - Platform.select({ - ios: { marginBottom: 24 + bottomBarHeight, }, - android: { marginBottom: 16 }, - default: { marginBottom: 16 }, - }) - ); - - if (!isValid) { - connectCallback(false, null, prepared.request, prepared.sessionCrypto); - return; - } + if (request.method === 'sendTransaction' && prepared) { + + // Callback to report the result of the transaction + const resultCallback = async ( + ok: boolean, + result: Cell | null + ) => { + try { + await connectCallback(ok, result, prepared.request, prepared.sessionCrypto); + } catch (error) { + toaster.show({ + message: !ok + ? t('products.transactionRequest.failedToReportCanceled') + : t('products.transactionRequest.failedToReport'), + ...toastProps, + type: 'error', + duration: ToastDuration.LONG + }); + } + }; navigation.navigateTransfer({ text: null, @@ -116,7 +76,7 @@ export const TonConnectRequestButton = memo((props: TonConnectRequestButtonProps } : undefined }, job: null, - callback: (ok, result) => connectCallback(ok, result, prepared.request, prepared.sessionCrypto) + callback: resultCallback }); } }, [prepareConnectRequest, props, connectCallback]); diff --git a/app/fragments/wallet/views/TransactionView.tsx b/app/fragments/wallet/views/TransactionView.tsx index 4f931cd68..f5c1f56ee 100644 --- a/app/fragments/wallet/views/TransactionView.tsx +++ b/app/fragments/wallet/views/TransactionView.tsx @@ -25,6 +25,7 @@ import { TxAvatar } from './TxAvatar'; import { PreparedMessageView } from './PreparedMessageView'; import { useContractInfo } from '../../../engine/hooks/metadata/useContractInfo'; import { ForcedAvatarType } from '../../../components/avatar/ForcedAvatar'; +import { isTxSPAM } from '../../../utils/spam/isTxSPAM'; export function TransactionView(props: { own: Address, @@ -73,9 +74,8 @@ export function TransactionView(props: { const avatarColorHash = walletSettings?.color ?? avatarHash(parsedAddressFriendly, avatarColors.length); const avatarColor = avatarColors[avatarColorHash]; - const contact = contacts[parsedAddressFriendly]; - const isSpam = !!denyList[parsedAddressFriendly]?.reason; + const verified = !!tx.verified; // Operation const op = useMemo(() => { @@ -134,15 +134,16 @@ export function TransactionView(props: { known = { name: walletSettings.name } } - let spam = - !!spamWallets.find((i) => opAddress === i) - || isSpam - || ( - absAmount < spamMinAmount - && !!tx.base.operation.comment - && !knownWallets[parsedAddressFriendly] - && !isTestnet - ) && kind !== 'out'; + let spam = isTxSPAM( + tx, + { + knownWallets, + isDenyAddress: (addressString?: string | null) => !!denyList[addressString ?? '']?.reason, + spamWallets, + spamMinAmount, + isTestnet + } + ); if (preparedMessages.length > 1) { @@ -219,6 +220,7 @@ export function TransactionView(props: { avatarColor={avatarColor} knownWallets={knownWallets} forceAvatar={forcedAvatar} + verified={verified} /> diff --git a/app/fragments/wallet/views/TxAvatar.tsx b/app/fragments/wallet/views/TxAvatar.tsx index 52be30a30..789c9d783 100644 --- a/app/fragments/wallet/views/TxAvatar.tsx +++ b/app/fragments/wallet/views/TxAvatar.tsx @@ -19,6 +19,7 @@ export const TxAvatar = memo(( avatarColor, knownWallets, forceAvatar, + verified }: { status: "failed" | "pending" | "success", parsedAddressFriendly: string, @@ -31,6 +32,7 @@ export const TxAvatar = memo(( avatarColor: string, knownWallets: { [key: string]: KnownWallet }, forceAvatar?: ForcedAvatarType, + verified?: boolean } ) => { @@ -48,7 +50,18 @@ export const TxAvatar = memo(( } if (forceAvatar) { - return (); + return ( + + ); } return ( @@ -69,6 +82,7 @@ export const TxAvatar = memo(( knownWallets={knownWallets} backgroundColor={avatarColor} hash={walletSettings?.avatar} + verified={verified} /> ); }); \ No newline at end of file diff --git a/app/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 81baa53fc..11a7aae53 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -313,6 +313,11 @@ const schema: PrepareSchema = { wrongNetwork: 'Wrong network', wrongFrom: 'Wrong sender', invalidFrom: 'Invalid sender address', + noConnection: 'App is not connected', + expired: 'Request expired', + invalidRequest: 'Invalid request', + failedToReport: 'Transaction is sent but failed to report back to the app', + failedToReportCanceled: 'Transaction is canceled but failed to report back to the app' }, signatureRequest: { title: 'Signature requested', @@ -625,7 +630,7 @@ const schema: PrepareSchema = { addNew: 'Add new wallet', inProgress: 'Creating...', backupTitle: 'Your Backup Key', - backupSubtitle: 'Write down this words in exactly the same order and save them in a secret place', + backupSubtitle: 'Write down these 24 words in exactly the same order and save them in a secret place', okSaved: 'OK, I saved it', copy: 'Copy to clipboard', }, @@ -654,7 +659,7 @@ const schema: PrepareSchema = { onLaterMessage: 'You can setup protection later in settings', onLaterButton: 'Setup later', onBiometricsError: 'Error authenticating with biometrics', - lockAppWithAuth: 'Lock app with authentication', + lockAppWithAuth: 'Authenticate when logging into the app', methodPasscode: 'passcode', passcodeSetupDescription: 'PIN code helps to protect your wallet from unauthorized access' }, @@ -1065,6 +1070,14 @@ const schema: PrepareSchema = { termsAndPrivacy: 'I have read and agree to the ', dontShowTitle: 'Don\'t show it again for DeDust.io', }, + mandatoryAuth: { + title: 'Check your backup', + description: 'Enable verification when opening a wallet. This will help keep your bank card details safe.', + alert: 'Write down 24 secret words in the Security section of your wallet settings. This will help you regain access if you lose your phone or forget your pin code.', + confirmDescription: 'I wrote down my wallet 24 secret words and saved them in a safe place', + action: 'Enable', + settingsDescription: 'Authentication request is required as the app displays banking products. Sensitive data will be hidden until you turn the authentication on', + } }; export default schema; diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index b2a4b99f6..8587e3157 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -312,7 +312,12 @@ const schema: PrepareSchema = { "groupTitle": "Запросы на подтверждение", "wrongNetwork": "Неверная сеть", "wrongFrom": "Неверный адрес отправителя", - "invalidFrom": "Невалидный адрес отправителя" + "invalidFrom": "Невалидный адрес отправителя", + "noConnection": "Приложение не подключено", + "expired": "Запрос истек", + "invalidRequest": "Неверный запрос", + "failedToReport": "Транзакция отправлена, но не удалось ответить приложению", + "failedToReportCanceled": "Транзакция отменена, но не удалось ответить приложению" }, "signatureRequest": { "title": "Запрос на подпись", @@ -625,7 +630,7 @@ const schema: PrepareSchema = { "addNew": "Создать новый кошелек", "inProgress": "Создаем...", "backupTitle": "Ваша seed-фраза", - "backupSubtitle": "Запишите эти слова в том же порядке и сохраните их в надежном месте", + "backupSubtitle": "Запишите эти 24 слова в том же порядке и сохраните их в надежном месте", "okSaved": "ОК, всё записано", "copy": "Скопировать в буфер обмена" }, @@ -642,9 +647,9 @@ const schema: PrepareSchema = { "subtitleUnprotected": "Мы рекомендуем включить пароль на вашем устройстве для защиты ваших активов.", "subtitleNoBiometrics": "Мы рекомендуем включить биометрию на вашем устройстве для защиты ваших активов. Мы используем ее для подтверждения транзакций. Вы можете быть уверены в том, что доступ к вашим средствам есть только у вас.", "messageNoBiometrics": "Мы рекомендуем включить биометрию на вашем устройстве для защиты ваших активов.", - "protectFaceID": "Защитить с Face ID", - "protectTouchID": "Защитить с Touch ID", - "protectBiometrics": "Защитить биометрией", + "protectFaceID": "Использовать Face ID", + "protectTouchID": "Использовать Touch ID", + "protectBiometrics": "Использовать биометрию", "protectPasscode": "Защитить паролем устройства", "upgradeTitle": "Требуется обновление", "upgradeMessage": "Пожалуйста, разрешите приложению доступ к ключам для обновления. Обязательно убедитесь в том, что ваши секретные слова надежно сохранены. Все средства находящиеся на вашем балансе сохранятся.", @@ -654,7 +659,7 @@ const schema: PrepareSchema = { "onLaterMessage": "Вы можете включить защиту позже в настройках приложения", "onLaterButton": "Включить позже", "onBiometricsError": "Ошибка подтверждения биометрии", - "lockAppWithAuth": "Запрашивать пин-код при входе", + "lockAppWithAuth": "Авторизация при входе в приложение", "methodPasscode": "паролем", "passcodeSetupDescription": "Пин-код помогает защитить ваш кошелек от несанкционированного доступа" }, @@ -810,7 +815,7 @@ const schema: PrepareSchema = { "restore": "Восстановить" }, "canceled": { - "title": "Отменено", + "title": "Отменa", "message": "Аутентификация была отменена, пожалуйста, повторите попытку" } } @@ -1065,6 +1070,14 @@ const schema: PrepareSchema = { "termsAndPrivacy": 'Я прочитал и согласен с ', "dontShowTitle": 'Больше не показывать для DeDust.io', }, + "mandatoryAuth": { + "title": 'Проверьте Seed фразу', + "description": 'Включите верификацию при открытии кошелька. Храните данные ваших карт в безопасности.', + "alert": 'Сохраните 24 секретных слова (Seed фразу) от вашего кошелька. Это поможет вам восстановить доступ, если вы потеряете телефон или забудете пин-код.', + "confirmDescription": '24 секретных слова записаны и хранятся в надежном месте', + "action": 'Включить', + "settingsDescription": 'Авторизация обязательна для отображения банковских продуктов. Чувствительные данные будут скрыты, пока вы не включите авторизацию', + } }; export default schema; \ No newline at end of file diff --git a/app/i18n/schema.ts b/app/i18n/schema.ts index c3ee5b67a..244ab6bc3 100644 --- a/app/i18n/schema.ts +++ b/app/i18n/schema.ts @@ -315,6 +315,11 @@ export type LocalizationSchema = { wrongNetwork: string, wrongFrom: string, invalidFrom: string, + noConnection: string, + expired: string, + invalidRequest: string, + failedToReport: string, + failedToReportCanceled: string, }, signatureRequest: { title: string, @@ -1067,6 +1072,14 @@ export type LocalizationSchema = { termsAndPrivacy: string, dontShowTitle: string, }, + mandatoryAuth: { + title: string, + description: string, + alert: string, + confirmDescription: string, + action: string, + settingsDescription: string + } }; export type LocalizedResources = Paths; diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index 904219c90..db807b7a6 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -1,17 +1,17 @@ import { t } from './i18n/t'; -import { useTypedNavigation } from './utils/useTypedNavigation'; +import { TypedNavigation, useTypedNavigation } from './utils/useTypedNavigation'; import { ResolvedUrl } from './utils/resolveUrl'; import { Queries } from './engine/queries'; -import { useSetAppState } from './engine/hooks'; +import { useConnectPendingRequests, useSetAppState } from './engine/hooks'; import { useSelectedAccount } from './engine/hooks'; import { InfiniteData, useQueryClient } from '@tanstack/react-query'; -import { Address } from '@ton/core'; +import { Address, Cell, fromNano, toNano } from '@ton/core'; import { fetchAccountTransactions } from './engine/api/fetchAccountTransactions'; import { contractMetadataQueryFn, jettonMasterContentQueryFn } from './engine/hooks/jettons/usePrefetchHints'; import { getJettonMasterAddressFromMetadata, parseStoredMetadata } from './engine/hooks/transactions/useAccountTransactions'; -import { getAppState } from './storage/appState'; -import { useCallback } from 'react'; -import { ToastDuration, useToaster } from './components/toast/ToastProvider'; +import { AppState, getAppState } from './storage/appState'; +import { MutableRefObject, useCallback, useEffect, useRef } from 'react'; +import { ToastDuration, Toaster, useToaster } from './components/toast/ToastProvider'; import { jettonWalletAddressQueryFn, jettonWalletQueryFn } from './engine/hooks/jettons/usePrefetchHints'; import { useGlobalLoader } from './components/useGlobalLoader'; import { StoredJettonWallet } from './engine/metadata/StoredMetadata'; @@ -20,9 +20,239 @@ import { getQueryData } from './engine/utils/getQueryData'; import { StoredTransaction } from './engine/types'; import { TonConnectAuthType } from './fragments/secure/dapps/TonConnectAuthenticateFragment'; import { warn } from './utils/log'; +import { getFullConnectionsMap, getStoredConnectExtensions } from './engine/state/tonconnect'; +import { ConnectedAppConnectionRemote, ConnectPushQuery, SendTransactionError, SendTransactionRequest, SignRawParams, TonConnectBridgeType } from './engine/tonconnect/types'; +import { AppRequest, Base64, CHAIN, hexToByteArray, RpcMethod, SEND_TRANSACTION_ERROR_CODES, SessionCrypto, WalletResponse } from '@tonconnect/protocol'; +import { transactionRpcRequestCodec } from './engine/tonconnect/codecs'; +import { sendTonConnectResponse } from './engine/api/sendTonConnectResponse'; +import { extensionKey } from './engine/hooks/dapps/useAddExtension'; +import { ConnectedApp } from './engine/hooks/dapps/useTonConnectExtenstions'; +import { TransferFragmentProps } from './fragments/secure/TransferFragment'; +import { extractDomain } from './engine/utils/extractDomain'; +import { Linking } from 'react-native'; +import { openWithInApp } from './utils/openWithInApp'; const infoBackoff = createBackoff({ maxFailureCount: 10 }); +function tryResolveTonconnectRequest( + params: { + query: ConnectPushQuery, + isTestnet: boolean, + toaster: Toaster, + navigation: TypedNavigation, + pendingReqsUpdaterRef: MutableRefObject<(updater: (currVal: SendTransactionRequest[]) => SendTransactionRequest[]) => void>, + updateAppState: (value: AppState, isTestnet: boolean) => void, + toastProps?: { duration?: ToastDuration, marginBottom?: number } + } +) { + const { + query, + toaster, toastProps, + navigation, + isTestnet, + pendingReqsUpdaterRef, + updateAppState + } = params; + + try { + const isFresh = query.validUntil > Math.floor(Date.now() / 1000); + const message = query.message; + const from = query.from; + const to = query.to; + + const appState = getAppState(); + const address = Address.parse(to); + const index = appState.addresses.findIndex((a) => a.address.equals(address)); + + // Check if address is valid & is imported + if (index === -1) { + toaster.show({ + message: t('products.transactionRequest.invalidFrom'), + ...toastProps, type: 'error' + }); + return; + } + + // Check if request is fresh + if (!isFresh) { + toaster.show({ + message: t('products.transactionRequest.expired'), + ...toastProps, type: 'error' + }); + return; + } + + // Find connected app with appConnection + const allAppsMap = getStoredConnectExtensions(address.toString({ testOnly: isTestnet })); + const allConnectionsMap = getFullConnectionsMap(); + const allTargetConnectionsMap = allConnectionsMap[address.toString({ testOnly: isTestnet })]; + + let appConnection: { app: ConnectedApp, session: ConnectedAppConnectionRemote } | null = null; + + // Find connected app with appConnection + for (const app of Object.values(allAppsMap)) { + const appConnections = allTargetConnectionsMap[extensionKey(app.url)]; + if (appConnections) { + const session = appConnections.find((item) => { + return item.type === TonConnectBridgeType.Remote && item.clientSessionId === from; + }); + if (!!session) { + appConnection = { app, session: session as ConnectedAppConnectionRemote }; + break; + } + } + } + + if (!appConnection) { + toaster.show({ + message: t('products.transactionRequest.noConnection'), + ...toastProps, type: 'error' + }); + return; + } + + const sessionCrypto = new SessionCrypto(appConnection.session.sessionKeyPair); + const decryptedRequest = sessionCrypto.decrypt( + Base64.decode(message).toUint8Array(), + hexToByteArray(from), + ); + const parsed = JSON.parse(decryptedRequest); + + // validate request + if (!transactionRpcRequestCodec.is(parsed)) { + toaster.show({ + message: t('products.transactionRequest.invalidRequest'), + ...toastProps, type: 'error' + }); + return; + } + + const request = parsed as AppRequest; + + // transaction request + if (request.method === 'sendTransaction') { + const callback = (response: WalletResponse) => sendTonConnectResponse({ response, sessionCrypto, clientSessionId: from }); + const params = JSON.parse(request.params[0]) as SignRawParams; + + // check if request is valid + const isValidRequest = + params && typeof params.valid_until === 'number' && + Array.isArray(params.messages) && + params.messages.every((msg) => !!msg.address && !!msg.amount); + + if (!isValidRequest) { + // report error + callback({ + error: { + code: SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + message: 'Bad request', + }, + id: request.id.toString(), + }); + return; + } + + // check if network is correct + if (!!params.network) { + const walletNetwork = isTestnet ? CHAIN.TESTNET : CHAIN.MAINNET; + if (params.network !== walletNetwork) { + toaster.show({ + message: t('products.transactionRequest.wrongNetwork'), + ...toastProps, type: 'error' + }); + callback({ + error: { + code: SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, + message: 'Invalid network', + }, + id: request.id.toString(), + }); + return; + } + } + + // compile messages + const messages = []; + for (const message of params.messages) { + try { + const msg = { + amount: toNano(fromNano(message.amount)), + target: message.address, + amountAll: false, + payload: message.payload ? Cell.fromBoc(Buffer.from(message.payload, 'base64'))[0] : null, + stateInit: message.stateInit ? Cell.fromBoc(Buffer.from(message.stateInit, 'base64'))[0] : null + } + messages.push(msg); + } catch { + // ignore invalid messages + } + } + + // clear all current requests for this clientSessionId + const clearFromRequests = () => { + const updater = pendingReqsUpdaterRef.current; + updater((prev) => prev.filter((req) => req.from !== from)); + } + + // result callback + const responseCallback = async (ok: boolean, result: Cell | null) => { + try { + await sendTonConnectResponse({ + response: !ok + ? new SendTransactionError( + request.id, + SEND_TRANSACTION_ERROR_CODES.USER_REJECTS_ERROR, + 'Wallet declined the request', + ) + : { result: result?.toBoc({ idx: false }).toString('base64') ?? '', id: request.id }, + sessionCrypto, + clientSessionId: from + }); + } catch { + toaster.show({ + message: !ok + ? t('products.transactionRequest.failedToReportCanceled') + : t('products.transactionRequest.failedToReport'), + ...toastProps, + type: 'error' + }); + } + // avoid double sending + clearFromRequests(); + }; + + const prepared: TransferFragmentProps = { + text: null, job: null, + order: { + type: 'order', + messages: messages, + app: { title: appConnection.app.name, domain: extractDomain(appConnection.app.url), url: appConnection.app.url } + }, + callback: responseCallback + }; + + // check if "to" address is selected + const isSelected = appState.selected === index; + + if (!isSelected) { + // Select new address + updateAppState({ ...appState, selected: index }, isTestnet); + // navigate to home with tx to be opened after + navigation.navigateAndReplaceHome({ navigateTo: { type: 'tonconnect-request', request: prepared } }); + } else { + navigation.navigateTransfer(prepared); + } + } + } catch { + warn('Failed to resolve TonConnect request'); + toaster.show({ + message: t('products.transactionRequest.invalidRequest'), + ...toastProps, type: 'error' + }); + return; + } +} + export function useLinkNavigator( isTestnet: boolean, toastProps?: { duration?: ToastDuration, marginBottom?: number }, @@ -35,6 +265,14 @@ export function useLinkNavigator( const toaster = useToaster(); const loader = useGlobalLoader(); + const [, updatePendingReuests] = useConnectPendingRequests(); + const pendingReqsUpdaterRef = useRef(updatePendingReuests); + + useEffect(() => { + pendingReqsUpdaterRef.current = updatePendingReuests; + }, [updatePendingReuests]); + + // TODO: split this function into smaller functions const handler = useCallback(async (resolved: ResolvedUrl) => { if (resolved.type === 'transaction') { if (resolved.payload) { @@ -230,7 +468,9 @@ export function useLinkNavigator( const address = Address.parse(resolved.address); const index = appState.addresses.findIndex((a) => a.address.equals(address)); - if (index === -1) { + + // If address is found, select it + if (index !== -1) { // Select new address updateAppState({ ...appState, selected: index }, isTestnet); @@ -248,6 +488,29 @@ export function useLinkNavigator( hideloader(); } } + + if (resolved.type === 'tonconnect-request') { + tryResolveTonconnectRequest({ + query: resolved.query, + isTestnet, + toaster, + toastProps, + navigation, + pendingReqsUpdaterRef, + updateAppState + }); + } + + if (resolved.type === 'external-url') { + Linking.openURL(resolved.url); + return; + } + + if (resolved.type === 'in-app-url') { + openWithInApp(resolved.url); + return; + } + }, [selected, updateAppState]); return handler; diff --git a/app/utils/CachedLinking.ts b/app/utils/CachedLinking.ts index e6738cc7c..e1b43934e 100644 --- a/app/utils/CachedLinking.ts +++ b/app/utils/CachedLinking.ts @@ -1,7 +1,5 @@ import { Linking } from "react-native"; import * as Notifications from 'expo-notifications'; -import { MixpanelEvent, trackEvent } from "../analytics/mixpanel"; -import { IS_SANDBOX } from '../engine/state/network'; let lastLink: string | null = null; let listener: (((link: string) => void) | null) = null; @@ -24,7 +22,6 @@ function handleLinkReceived(link: string) { // Subscribe for links Linking.addEventListener('url', (e) => { - trackEvent(MixpanelEvent.LinkReceived, { url: e.url }, IS_SANDBOX); handleLinkReceived(e.url); }); @@ -32,7 +29,6 @@ Linking.addEventListener('url', (e) => { Notifications.addNotificationResponseReceivedListener((response) => { let data = response.notification.request.content.data; if (data && typeof data['url'] === 'string') { - trackEvent(MixpanelEvent.NotificationReceived, { url: data['url'] }, IS_SANDBOX); handleLinkReceived(data['url']); } }); diff --git a/app/utils/hintSortFilter.ts b/app/utils/hintSortFilter.ts index c97b238c9..50bd585b1 100644 --- a/app/utils/hintSortFilter.ts +++ b/app/utils/hintSortFilter.ts @@ -5,6 +5,8 @@ import { Queries } from "../engine/queries"; import { verifyJetton } from "../engine/hooks/jettons/useVerifyJetton"; import { JettonMasterState } from "../engine/metadata/fetchJettonMasterContent"; import { getQueryData } from "../engine/utils/getQueryData"; +import { QueryCache } from "@tanstack/react-query"; +import { jettonMasterContentQueryFn, jettonWalletQueryFn } from "../engine/hooks/jettons/usePrefetchHints"; type Hint = { address: string, @@ -40,7 +42,7 @@ export function filterHint(filter: HintsFilter[]): (hint: Hint) => boolean { if (!hint.loaded) { return false; } - + if (filter.includes('verified') && !hint.verified) { return false; } @@ -57,19 +59,27 @@ export function filterHint(filter: HintsFilter[]): (hint: Hint) => boolean { } } -export function getHint(hint: string, isTestnet: boolean): Hint { +export function getHint(queryCache: QueryCache, hint: string, isTestnet: boolean): Hint { try { const wallet = Address.parse(hint); - const queryCache = queryClient.getQueryCache(); const contractMeta = getQueryData(queryCache, Queries.ContractMetadata(hint)); const jettonWallet = getQueryData(queryCache, Queries.Account(wallet.toString({ testOnly: isTestnet })).JettonWallet()); - const masterStr = contractMeta?.jettonWallet?.master ?? jettonWallet?.master ?? null; - const masterContent = getQueryData(queryCache, Queries.Jettons().MasterContent(masterStr ?? '')); - const swap = getQueryData(queryCache, Queries.Jettons().Swap(masterStr ?? '')); + const masterStr = contractMeta?.jettonWallet?.master ?? jettonWallet?.master ?? ''; + const masterContent = getQueryData(queryCache, Queries.Jettons().MasterContent(masterStr)); + const swap = getQueryData(queryCache, Queries.Jettons().Swap(masterStr)); const { verified, isSCAM } = verifyJetton({ ticker: masterContent?.symbol, master: masterStr }, isTestnet); if (!jettonWallet || !masterContent) { + // prefetch jetton wallet & master content + queryClient.prefetchQuery({ + queryKey: Queries.Account(wallet.toString({ testOnly: isTestnet })).JettonWallet(), + queryFn: jettonWalletQueryFn(wallet.toString({ testOnly: isTestnet }), isTestnet) + }); + queryClient.prefetchQuery({ + queryKey: Queries.Jettons().MasterContent(masterStr), + queryFn: jettonMasterContentQueryFn(masterStr, isTestnet) + }); return { address: hint }; } diff --git a/app/utils/resolveUrl.ts b/app/utils/resolveUrl.ts index acc0d1401..0710f8869 100644 --- a/app/utils/resolveUrl.ts +++ b/app/utils/resolveUrl.ts @@ -3,7 +3,7 @@ import Url from 'url-parse'; import { warn } from "./log"; import { SupportedDomains } from "./SupportedDomains"; import isValid from 'is-valid-domain'; -import { ConnectQrQuery } from "../engine/tonconnect/types"; +import { ConnectPushQuery, ConnectQrQuery } from "../engine/tonconnect/types"; export enum ResolveUrlError { InvalidAddress = 'InvalidAddress', @@ -14,6 +14,8 @@ export enum ResolveUrlError { InvalidJettonFee = 'InvalidJettonFee', InvalidJettonForward = 'InvalidJettonForward', InvalidJettonAmounts = 'InvalidJettonAmounts', + InvalidInappUrl = 'InvalidInappUrl', + InvalidExternalUrl = 'InvalidExternalUrl', } export type ResolvedUrl = { @@ -44,11 +46,20 @@ export type ResolvedUrl = { } | { type: 'tonconnect', query: ConnectQrQuery +} | { + type: 'tonconnect-request', + query: ConnectPushQuery } | { type: 'tx', address: string, hash: string, lt: string +} | { + type: 'in-app-url', + url: string, +} | { + type: 'external-url', + url: string, } | { type: 'error', error: ResolveUrlError @@ -195,159 +206,189 @@ export function resolveUrl(src: string, testOnly: boolean): ResolvedUrl | null { try { const url = new Url(src, true); + const isTonUrl = url.protocol.toLowerCase() === 'ton:' || url.protocol.toLowerCase() === 'ton-test:'; + const isHttpUrl = url.protocol.toLowerCase() === 'http:' || url.protocol.toLowerCase() === 'https:'; + // ton url - if ((url.protocol.toLowerCase() === 'ton:' || url.protocol.toLowerCase() === 'ton-test:') && url.host.toLowerCase() === 'transfer' && url.pathname.startsWith('/')) { - return resolveTransferUrl(url); - } + if (isTonUrl) { + + if (url.host.toLowerCase() === 'transfer' && url.pathname.startsWith('/')) { - // ton url connect - if ((url.protocol.toLowerCase() === 'ton:' || url.protocol.toLowerCase() === 'ton-test:') && url.host.toLowerCase() === 'connect' && url.pathname.startsWith('/')) { - let session = url.pathname.slice(1); - let endpoint: string | null = null; - if (url.query) { - for (let key in url.query) { - if (key.toLowerCase() === 'endpoint') { - endpoint = url.query[key]!; + return resolveTransferUrl(url); + + } else if (url.host.toLowerCase() === 'connect' && url.pathname.startsWith('/')) { + + let session = url.pathname.slice(1); + let endpoint: string | null = null; + if (url.query) { + for (let key in url.query) { + if (key.toLowerCase() === 'endpoint') { + endpoint = url.query[key]!; + } } } - } - return { - type: 'connect', - session, - endpoint - } - } - // ton url tx - if ( - (url.protocol.toLowerCase() === 'ton:' || url.protocol.toLowerCase() === 'ton-test:') - && url.host.toLowerCase() === 'tx' - && url.pathname.startsWith('/') - ) { - const address = decodeURIComponent(url.pathname.slice(1).split('/')[0]); - const txId = url.pathname.slice(1).split('/')[1].split('_'); - const lt = txId[0]; - const hash = decodeURIComponent(txId[1]); - - return { - type: 'tx', - address, - hash, - lt - } - } + return { + type: 'connect', + session, + endpoint + } - // HTTP(s) url - if ((url.protocol.toLowerCase() === 'http:' || url.protocol.toLowerCase() === 'https:') - && (SupportedDomains.find((d) => d === url.host.toLowerCase())) - && (url.pathname.toLowerCase().startsWith('/transfer/'))) { - return resolveTransferUrl(url); - } + } else if (url.host.toLowerCase() === 'tx' && url.pathname.startsWith('/')) { - // HTTP(s) Sign Url - if ((url.protocol.toLowerCase() === 'http:' || url.protocol.toLowerCase() === 'https:') - && (SupportedDomains.find((d) => d === url.host.toLowerCase())) - && (url.pathname.toLowerCase().startsWith('/connect/'))) { - let session = url.pathname.slice('/connect/'.length); - let endpoint: string | null = null; - if (url.query) { - for (let key in url.query) { - if (key.toLowerCase() === 'endpoint') { - endpoint = url.query[key]!; - } + const address = decodeURIComponent(url.pathname.slice(1).split('/')[0]); + const txId = url.pathname.slice(1).split('/')[1].split('_'); + const lt = txId[0]; + const hash = decodeURIComponent(txId[1]); + + return { + type: 'tx', + address, + hash, + lt } - } - return { - type: 'connect', - session, - endpoint - } - } - } catch (e) { - // Ignore - warn(e); - } + } else if (url.host.toLowerCase() === 'tx' && url.pathname.startsWith('/')) { + const address = decodeURIComponent(url.pathname.slice(1).split('/')[0]); + const txId = url.pathname.slice(1).split('/')[1].split('_'); + const lt = txId[0]; + const hash = decodeURIComponent(txId[1]); - // Parse apps - try { - const url = new Url(src, true); - if ((url.protocol.toLowerCase() === 'https:') - && ((testOnly ? 'test.tonhub.com' : 'tonhub.com') === url.host.toLowerCase()) - && (url.pathname.toLowerCase().startsWith('/app/'))) { - let id = url.pathname.slice('/app/'.length); - let slice = Cell.fromBoc(Buffer.from(id, 'base64'))[0].beginParse(); - let endpointSlice = slice.loadRef().beginParse(); - let endpoint = endpointSlice.loadBuffer(endpointSlice.remainingBits / 8).toString(); - let extras = slice.loadBit(); // For future compatibility - let customTitle: string | null = null; - let customImage: { url: string, blurhash: string } | null = null; - if (!extras) { - if (slice.remainingBits !== 0 || slice.remainingRefs !== 0) { - throw Error('Invalid endpoint'); + return { + type: 'tx', + address, + hash, + lt } - } else { - if (slice.loadBit()) { - let customTitleSlice = slice.loadRef().beginParse(); - customTitle = customTitleSlice.loadBuffer(customTitleSlice.remainingBits / 8).toString(); - if (customTitle.trim().length === 0) { - customTitle = null; + } + + } + + if (isHttpUrl) { + const isSupportedDomain = SupportedDomains.find((d) => d === url.host.toLowerCase()); + const isTonhubHost = (testOnly ? 'test.tonhub.com' : 'tonhub.com') === url.host.toLowerCase(); + + // Transfer + if (isSupportedDomain && url.pathname.toLowerCase().startsWith('/transfer/')) { + return resolveTransferUrl(url); + } else if (isSupportedDomain && url.pathname.toLowerCase().startsWith('/connect/')) { // Ton-x app connect + let session = url.pathname.slice('/connect/'.length); + let endpoint: string | null = null; + + if (url.query) { + for (let key in url.query) { + if (key.toLowerCase() === 'endpoint') { + endpoint = url.query[key]!; + } } } - if (slice.loadBit()) { - let imageUrlSlice = slice.loadRef().beginParse(); - let imageUrl = imageUrlSlice.loadBuffer(imageUrlSlice.remainingBits / 8).toString(); - let imageBlurhashSlice = slice.loadRef().beginParse(); - let imageBlurhash = imageBlurhashSlice.loadBuffer(imageBlurhashSlice.remainingBits / 8).toString(); - new Url(imageUrl, true); // Check url - customImage = { url: imageUrl, blurhash: imageBlurhash }; - } - // Future compatibility - extras = slice.loadBit(); // For future compatibility + return { + type: 'connect', + session, + endpoint + } + } else if (isSupportedDomain && url.pathname.toLowerCase().indexOf('/ton-connect') !== -1) { // Tonconnect connect query + if (!!url.query.r && !!url.query.v && !!url.query.id) { + return { + type: 'tonconnect', + query: url.query as unknown as ConnectQrQuery + }; + } + } else if (isTonhubHost && url.pathname.toLowerCase().startsWith('/app/')) { // Ton-x app install + let id = url.pathname.slice('/app/'.length); + let slice = Cell.fromBoc(Buffer.from(id, 'base64'))[0].beginParse(); + let endpointSlice = slice.loadRef().beginParse(); + let endpoint = endpointSlice.loadBuffer(endpointSlice.remainingBits / 8).toString(); + let extras = slice.loadBit(); // For future compatibility + let customTitle: string | null = null; + let customImage: { url: string, blurhash: string } | null = null; if (!extras) { if (slice.remainingBits !== 0 || slice.remainingRefs !== 0) { throw Error('Invalid endpoint'); } - } - } + } else { + if (slice.loadBit()) { + let customTitleSlice = slice.loadRef().beginParse(); + customTitle = customTitleSlice.loadBuffer(customTitleSlice.remainingBits / 8).toString(); + if (customTitle.trim().length === 0) { + customTitle = null; + } + } + if (slice.loadBit()) { + let imageUrlSlice = slice.loadRef().beginParse(); + let imageUrl = imageUrlSlice.loadBuffer(imageUrlSlice.remainingBits / 8).toString(); + let imageBlurhashSlice = slice.loadRef().beginParse(); + let imageBlurhash = imageBlurhashSlice.loadBuffer(imageBlurhashSlice.remainingBits / 8).toString(); + new Url(imageUrl, true); // Check url + customImage = { url: imageUrl, blurhash: imageBlurhash }; + } - // Validate endpoint - let parsedEndpoint = new Url(endpoint, true); - if (parsedEndpoint.protocol !== 'https:') { - throw Error('Invalid endpoint'); - } - if (!isValid(parsedEndpoint.hostname)) { - throw Error('Invalid endpoint'); - } + // Future compatibility + extras = slice.loadBit(); // For future compatibility + if (!extras) { + if (slice.remainingBits !== 0 || slice.remainingRefs !== 0) { + throw Error('Invalid endpoint'); + } + } + } - return { - type: 'install', - url: endpoint, - customTitle, - customImage - }; - } + // Validate endpoint + let parsedEndpoint = new Url(endpoint, true); + if (parsedEndpoint.protocol !== 'https:') { + throw Error('Invalid endpoint'); + } + if (!isValid(parsedEndpoint.hostname)) { + throw Error('Invalid endpoint'); + } - // Tonconnect - if ((url.protocol.toLowerCase() === 'https:') - && (SupportedDomains.find((d) => d === url.host.toLowerCase())) - && (url.pathname.toLowerCase().indexOf('/ton-connect') !== -1)) { - if (!!url.query.r && !!url.query.v && !!url.query.id) { return { - type: 'tonconnect', - query: url.query as unknown as ConnectQrQuery + type: 'install', + url: endpoint, + customTitle, + customImage }; + } else if (isTonhubHost && url.pathname.toLowerCase() === '/inapp') { // open url with in-app browser + if (url.query && url.query.url) { + return { + type: 'in-app-url', + url: decodeURIComponent(url.query.url) + }; + } + } else if (isTonhubHost && url.pathname.toLowerCase() === '/external') { // open url with external browser + if (url.query && url.query.url) { + return { + type: 'external-url', + url: decodeURIComponent(url.query.url) + }; + } } } + // Tonconnect if (url.protocol.toLowerCase() === 'tc:') { - if (!!url.query.r && !!url.query.v && !!url.query.id) { + if ( + url.host === 'sendtransaction' + && !!url.query.message + && !!url.query.from + && !!url.query.validUntil + && !!url.query.to + ) { + const validUntil = parseInt(decodeURIComponent(url.query.validUntil)); + const from = decodeURIComponent(url.query.from); + const to = decodeURIComponent(url.query.to); + const message = decodeURIComponent(url.query.message); + return { + type: 'tonconnect-request', + query: { validUntil, from, to, message } + }; + } else if (!!url.query.r && !!url.query.v && !!url.query.id) { return { type: 'tonconnect', query: url.query as unknown as ConnectQrQuery }; } + } } catch (e) { @@ -355,7 +396,6 @@ export function resolveUrl(src: string, testOnly: boolean): ResolvedUrl | null { warn(e); } - return null; } diff --git a/app/utils/spam/isTxSPAM.ts b/app/utils/spam/isTxSPAM.ts new file mode 100644 index 000000000..330c3037f --- /dev/null +++ b/app/utils/spam/isTxSPAM.ts @@ -0,0 +1,93 @@ +import { Address } from "@ton/core"; +import { TransactionDescription } from "../../engine/types"; +import { KnownWallet } from "../../secure/KnownWallets"; +import { BigMath } from "../BigMath"; +import { SPAM_KEYWORDS_EN, SPAM_KEYWORDS_RU } from "./spamKeywords"; + +const triggerScore = 100; +const enKeys = Object.entries(SPAM_KEYWORDS_EN); +const ruKeys = Object.entries(SPAM_KEYWORDS_RU); + +function getKeywordsScore(str: string, keywords: [string, number][]) { + const parts = str.split(' ') + .map((pt) => pt.toLowerCase()) + // sub parts by \n and \r + .flatMap((pt) => pt.split('\n')) + .flatMap((pt) => pt.split('\r')); + + const included = parts.reduce((fullScore, part) => { + const score = keywords.reduce((sum, item) => { + const [key, value] = item; + return sum + (part.includes(key) ? value : 0); + }, 0); + return fullScore + score; + }, 0); + + return included; +} + +// Check if the comment contains any of the SPAM patterns +export function matchesWeightedKeywords(comment?: string | null) { + if (!comment) { + return false; + } + + const en_score = getKeywordsScore(comment, enKeys); + + // ealy return if the comment is already SPAM + if (en_score >= triggerScore) { + return true; + } + + // additional check for ru keywords + const ru_score = getKeywordsScore(comment, ruKeys); + + return (en_score + ru_score) >= triggerScore; +} + +export function isTxSPAM( + tx: TransactionDescription, + config: { + knownWallets: { [key: string]: KnownWallet }, + isDenyAddress: (addressString?: string | null) => boolean, + spamWallets: string[], + spamMinAmount: bigint, + isTestnet: boolean + } +) { + const kind = tx.base.parsed.kind; + const operation = tx.base.operation; + const type = tx.base.parsed.body?.type + const item = operation.items[0]; + const opAddress = item.kind === 'token' ? operation.address : tx.base.parsed.resolvedAddress; + const parsedOpAddr = Address.parseFriendly(opAddress); + const parsedAddress = parsedOpAddr.address; + const opAddressBounceable = parsedAddress.toString({ testOnly: config.isTestnet }); + + if (kind !== 'in' || config.isTestnet) { + return false; + } + + if (config.isDenyAddress(opAddressBounceable)) { + return true; + } + + if (config.spamWallets.includes(opAddressBounceable)) { + return true; + } + + if (!!config.knownWallets[opAddressBounceable]) { + return false; + } + + if (type === 'comment') { + const hasSPAMContext = matchesWeightedKeywords(operation.comment); + const spamAmount = BigMath.abs(BigInt(tx.base.parsed.amount)) < config.spamMinAmount; + + return hasSPAMContext || spamAmount; + } else if (type === 'payload' && item.kind === 'token') { // comments in token transfers + return matchesWeightedKeywords(operation.comment); + } + + return false; +} \ No newline at end of file diff --git a/app/utils/spam/matchesWeightedKeywords.spec.ts b/app/utils/spam/matchesWeightedKeywords.spec.ts new file mode 100644 index 000000000..abd1a3ccb --- /dev/null +++ b/app/utils/spam/matchesWeightedKeywords.spec.ts @@ -0,0 +1,48 @@ +import { matchesWeightedKeywords } from "./isTxSPAM"; + +const spamComments = [ + `🎁Your wallet has won: 1,000 $TON + CLAIM: https://tontp܂net + Thank you for your participation in the $TON company. + ❗Your reward is available, pick it up now`, + 'verification required to claim your prize http://scam.com', + `Telegram 'USDTAwards_bot' - Claim Your Awards`, + 'https://t.me/USDTAwards_bot', + 'Check out this link: https://t.me/TON_Crystal_Airdrop_Bot?start=1234567', + 'https://t.me/TON_Crystal_Airdrop_Bot?start=1234567', + 'Congratulations! You are the lucky winner of our 1000 TON giveaway! Please visit our website to claim your prize: scam.com' +]; + +const notSpamComments = [ + 'Deposit accepted', + 'NFT minted #123434455', + 'Not SPAM', + 'Hello world!', + 'Пополнение счета на 100USDT', + 'Withdraw pf 1000.321TON request accepted', +]; + +describe('matchesWeightedKeywords test', () => { + it('should return false if comment is null', () => { + const comment = null; + const result = matchesWeightedKeywords(comment); + expect(result).toBe(false); + }); + + it('should return true if comment is SPAM', () => { + spamComments.forEach(comment => { + const result = matchesWeightedKeywords(comment); + if (!result) { + expect(comment).toBe('SPAM'); + } + expect(result).toBe(true); + }); + }); + + it('should return false if comment is not SPAM', () => { + notSpamComments.forEach(comment => { + const result = matchesWeightedKeywords(comment); + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/app/utils/spam/spamKeywords.ts b/app/utils/spam/spamKeywords.ts new file mode 100644 index 000000000..d67ab2cd3 --- /dev/null +++ b/app/utils/spam/spamKeywords.ts @@ -0,0 +1,126 @@ +export const SPAM_KEYWORDS_EN: { [key: string]: number } = { + 'https://': 70, + 'http://': 70, + 'tg.': 70, + 't.me': 70, + 'www.': 70, + '.net': 50, + '.com': 50, + '.org': 50, + '.io': 50, + '.xyz': 50, + '_bot': 50, + '//': 20, + 'airdrop': 10, + 'net': 10, + 'com': 10, + 'org': 10, + 'io': 10, + 'xyz': 10, + 'www': 10, + 'free': 10, + 'giveaway': 10, + 'claim': 10, + 'claiming': 10, + 'claimable': 10, + 'won': 10, + 'win': 10, + 'has won': 30, + 'have won': 30, + 'winner': 10, + 'winning': 10, + 'lottery': 10, + 'prize': 10, + 'reward': 10, + 'bonus': 10, + 'promo': 10, + 'promotion': 10, + 'promotional': 10, + 'event': 10, + 'eventful': 10, + 'pick it up': 10, + 'pickup': 10, + 'has been selected': 30, + 'participate': 10, + 'participation': 10, + 'your wallet': 10, + 'click': 10, + 'collect': 10, + 'verification': 10, + 'verify': 10, + 'verified': 10, + 'earn': 10, + 'earning': 10, + 'earnings': 10, + 'profit': 10, + 'profitable': 10, + 'profitability': 10, + 'income': 10, + 'incomes': 10, + 'investment': 10, + 'investments': 10, + 'investing': 10, + 'investor': 10, + 'invest': 10, + 'telegram': 10, + 'bot': 10, + 'bots': 10, + 'awards': 10, +} + +export const SPAM_KEYWORDS_RU: { [key: string]: number } = { + 'розыгрыш': 10, + 'бесплатно': 10, + 'подарок': 10, + 'получить': 10, + 'получите': 10, + 'получил': 10, + 'получила': 10, + 'получили': 10, + 'победитель': 10, + 'победительница': 10, + 'победители': 10, + 'победительницы': 10, + 'победила': 10, + 'победил': 10, + 'приз': 10, + 'награда': 10, + 'бонус': 10, + 'промо': 10, + 'промоакция': 10, + 'промоакции': 10, + 'мероприятие': 10, + 'мероприятия': 10, + 'выиграл': 10, + 'выиграла': 10, + 'выйграла': 10, + 'выиграли': 10, + 'выйграли': 10, + 'выигрыш': 10, + 'выйгрыш': 10, + 'лотерея': 10, + 'участвуй': 10, + 'участвовать': 10, + 'участие': 10, + 'ваш кошелек': 10, + 'клик': 10, + 'собрать': 10, + 'проверка': 10, + 'проверьте': 10, + 'проверено': 10, + 'заработать': 10, + 'заработок': 10, + 'заработки': 10, + 'прибыль': 10, + 'прибыльный': 10, + 'прибыльность': 10, + 'доход': 10, + 'доходы': 10, + 'инвестиция': 10, + 'инвестиции': 10, + 'инвестирование': 10, + 'инвестор': 10, + 'инвестировать': 10, + 'телеграм': 10, + 'бот': 10, +} \ No newline at end of file diff --git a/app/utils/useTypedNavigation.ts b/app/utils/useTypedNavigation.ts index 7af734a1c..10592b160 100644 --- a/app/utils/useTypedNavigation.ts +++ b/app/utils/useTypedNavigation.ts @@ -14,6 +14,11 @@ import { ProductsListFragmentParams } from '../fragments/wallet/ProductsListFrag import { StakingFragmentParams } from '../fragments/staking/StakingFragment'; import { PendingTxPreviewParams } from '../fragments/wallet/PendingTxPreviewFragment'; import { HomeFragmentProps } from '../fragments/HomeFragment'; +import { MandatoryAuthSetupParams } from '../fragments/secure/MandatoryAuthSetupFragment'; +import { getLockAppWithAuthState } from '../engine/state/lockAppWithAuthState'; +import { getHasHoldersProducts } from '../engine/hooks/holders/useHasHoldersProducts'; +import { getCurrentAddress } from '../storage/appState'; +import { Platform } from 'react-native'; type Base = NavigationProp; @@ -39,6 +44,14 @@ export function typedNavigateAndReplaceAll(src: Base, name: string, params?: any src.reset({ index: 0, routes: [{ name, params }] }); } +function shouldTurnAuthOn(isTestnet: boolean) { + const isAppAuthOn = getLockAppWithAuthState(); + const currentAccount = getCurrentAddress(); + const hasAccounts = getHasHoldersProducts(currentAccount.address.toString({ testOnly: isTestnet })); + + return !isAppAuthOn && hasAccounts; +} + export class TypedNavigation { readonly base: any; constructor(navigation: any) { @@ -154,12 +167,30 @@ export class TypedNavigation { this.navigateAndReplaceAll('LedgerApp'); } - navigateHoldersLanding({ endpoint, onEnrollType }: { endpoint: string, onEnrollType: HoldersAppParams }) { - this.navigate('HoldersLanding', { endpoint, onEnrollType }); + navigateHoldersLanding({ endpoint, onEnrollType }: { endpoint: string, onEnrollType: HoldersAppParams }, isTestnet: boolean) { + if (shouldTurnAuthOn(isTestnet)) { + const callback = (success: boolean) => { + if (success) { // navigate only if auth is set up + this.navigate('HoldersLanding', { endpoint, onEnrollType }) + } + } + this.navigateMandatoryAuthSetup({ callback }); + } else { + this.navigate('HoldersLanding', { endpoint, onEnrollType }); + } } - navigateHolders(params: HoldersAppParams) { - this.navigate('Holders', params); + navigateHolders(params: HoldersAppParams, isTestnet: boolean) { + if (shouldTurnAuthOn(isTestnet)) { + const callback = (success: boolean) => { + if (success) { // navigate only if auth is set up + this.navigate('Holders', params); + } + } + this.navigateMandatoryAuthSetup({ callback }); + } else { + this.navigate('Holders', params); + } } navigateConnectAuth(params: TonConnectAuthProps) { @@ -204,6 +235,10 @@ export class TypedNavigation { navigatePendingTx(params: PendingTxPreviewParams) { this.navigate('PendingTransaction', params); } + + navigateMandatoryAuthSetup(params?: MandatoryAuthSetupParams) { + this.navigate('MandatoryAuthSetup', params); + } } export function useTypedNavigation() { diff --git a/assets/banners/banner-icon-placeholder.webp b/assets/banners/banner-icon-placeholder.webp new file mode 100644 index 000000000..1d935140c Binary files /dev/null and b/assets/banners/banner-icon-placeholder.webp differ diff --git a/assets/banners/banner-placeholder.webp b/assets/banners/banner-placeholder.webp new file mode 100644 index 000000000..6c3f8a788 Binary files /dev/null and b/assets/banners/banner-placeholder.webp differ diff --git a/assets/ic-warning-banner.svg b/assets/ic-warning-banner.svg new file mode 100644 index 000000000..556ee2930 --- /dev/null +++ b/assets/ic-warning-banner.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/jettons/knownJettons.json b/assets/jettons/knownJettons.json index 5f92c4a25..7f0a517e9 100644 --- a/assets/jettons/knownJettons.json +++ b/assets/jettons/knownJettons.json @@ -14,7 +14,8 @@ ], "specialJetton": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", "masters": { - "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs": {} + "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs": {}, + "EQD0laik0FgHV8aNfRhebi8GDG2rpDyKGXem0MBfya_Ew1-8": {} } } } \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0fb128fb9..034b962ca 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -24,7 +24,7 @@ PODS: - ExpoModulesCore - Expo (49.0.21): - ExpoModulesCore - - ExpoBlur (12.4.1): + - ExpoBlur (12.8.0): - ExpoModulesCore - ExpoCrypto (12.4.1): - ExpoModulesCore @@ -1047,7 +1047,7 @@ SPEC CHECKSUMS: EXImageLoader: 34b214f9387e98f3c73989f15d8d5b399c9ab3f7 EXNotifications: 09394cbd7165f9a4a00a53328aa09bf874bae717 Expo: 61a8e1aa94311557c137c0a4dfd4fe78281cfbb4 - ExpoBlur: a2c90bdfa4ff9f459cdb0f83191bddf020e3e2db + ExpoBlur: 2e733ec3aa76653040b29a833984f128a293295a ExpoCrypto: a382ab9a2fa91f0b511ce1fe4d6baecee40a1615 ExpoDevice: 1c1b0c9cad96c292c1de73948649cfd654b2b3c0 ExpoHaptics: 360af6898407ee4e8265d30a1a8fb16491a660eb diff --git a/ios/wallet/Info.plist b/ios/wallet/Info.plist index 6e10e6907..0d489b32c 100644 --- a/ios/wallet/Info.plist +++ b/ios/wallet/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.3.7 + 2.3.8 CFBundleSignature ???? CFBundleURLTypes @@ -41,7 +41,7 @@ CFBundleVersion - 201 + 202 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/package.json b/package.json index 4bdc7d326..6de5fd62a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wallet", - "version": "2.3.7", + "version": "2.3.8", "scripts": { "start": "expo start --dev-client", "android": "expo run:android", @@ -53,7 +53,7 @@ "expo-app-loading": "~2.0.0", "expo-asset": "~8.10.1", "expo-barcode-scanner": "~12.5.3", - "expo-blur": "~12.4.1", + "expo-blur": "12.8.0", "expo-camera": "~13.4.4", "expo-constants": "~14.4.2", "expo-crypto": "~12.4.1", diff --git a/yarn.lock b/yarn.lock index f0acfb069..9640e378e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5230,10 +5230,10 @@ expo-barcode-scanner@~12.5.3: dependencies: expo-image-loader "~4.3.0" -expo-blur@~12.4.1: - version "12.4.1" - resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-12.4.1.tgz#b391de84914ef9ece0702378e51e09461ad565ab" - integrity sha512-lGN8FS9LuGUlEriULTC62cCWyg5V7zSVQeJ6Duh1wSq8aAETinZ2/7wrT6o+Uhd/XVVxFNON2T25AGCOtMG6ew== +expo-blur@12.8.0: + version "12.8.0" + resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-12.8.0.tgz#f07ae2dbfc98d04a2f050258c9aca5433523b63b" + integrity sha512-ngM4x21Sg1kLnu1DtZA60+oQsKRXsXrzzZWU24THGnMNA36P0O1rTKbERxdExRvJavO5ls3Gu54ut+IvKFQhew== expo-camera@~13.4.4: version "13.4.4"