From 38bb46d6092e16a8de1fad1a8f28b73aa771fe35 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 21 Jun 2024 18:57:57 +0300 Subject: [PATCH 01/54] wip: adding tonconnect tx request url resolution --- app/engine/tonconnect/types.ts | 7 +++++++ app/utils/resolveUrl.ts | 23 +++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) 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/utils/resolveUrl.ts b/app/utils/resolveUrl.ts index acc0d1401..3443b0638 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', @@ -44,6 +44,9 @@ export type ResolvedUrl = { } | { type: 'tonconnect', query: ConnectQrQuery +} | { + type: 'tonconnect-tx', + query: ConnectPushQuery } | { type: 'tx', address: string, @@ -342,12 +345,28 @@ export function resolveUrl(src: string, testOnly: boolean): ResolvedUrl | null { } // 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-tx', + 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) { From 6fb502ef89af3f7693eca7e3e498d454bc4fd416 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 24 Jun 2024 15:29:42 +0300 Subject: [PATCH 02/54] fix: fixing selecting received in push existing address --- app/useLinkNavigator.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index 904219c90..676a5837b 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -230,7 +230,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); From 639c7e6fa6ce082b6afe726ca639cc8ad194821f Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 25 Jun 2024 18:55:42 +0300 Subject: [PATCH 03/54] wip: fixing tonconnect watcher & adding tc req push url resolver --- app/Navigation.tsx | 5 +- app/components/TonconnectWatcher.tsx | 7 + .../hooks/dapps/useConnectPendingRequests.ts | 3 +- app/engine/hooks/dapps/useHandleMessage.ts | 2 +- app/engine/state/tonconnect.ts | 4 +- app/engine/tonconnectWatcher.ts | 27 ++- app/fragments/HomeFragment.tsx | 4 + app/i18n/i18n_en.ts | 4 + app/i18n/i18n_ru.ts | 6 +- app/i18n/schema.ts | 4 + app/useLinkNavigator.ts | 212 +++++++++++++++++- app/utils/resolveUrl.ts | 6 +- 12 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 app/components/TonconnectWatcher.tsx diff --git a/app/Navigation.tsx b/app/Navigation.tsx index 12b8d3df5..a70c15101 100644 --- a/app/Navigation.tsx +++ b/app/Navigation.tsx @@ -94,6 +94,7 @@ 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'; const Stack = createNativeStackNavigator(); Stack.Navigator.displayName = 'MainStack'; @@ -409,9 +410,6 @@ export const Navigation = memo(() => { // Watch blocks useBlocksWatcher(); - // Watch for TonConnect requests - useTonconnectWatcher(); - // Watch for holders updates useHoldersWatcher(); @@ -441,6 +439,7 @@ export const Navigation = memo(() => { + ); 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/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..7f4692475 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') { 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/tonconnectWatcher.ts b/app/engine/tonconnectWatcher.ts index fdfaa219a..af487bcdd 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'); @@ -62,6 +63,8 @@ export function useTonconnectWatcher() { } }); + watcher.open(); + return () => { if (watcher) { watcher.removeAllEventListeners(); @@ -71,5 +74,5 @@ export function useTonconnectWatcher() { logger.log('sse close'); } }; - }, [handleMessage, connections, session]); + }, [connections, session]); } \ No newline at end of file diff --git a/app/fragments/HomeFragment.tsx b/app/fragments/HomeFragment.tsx index 6031b1b9b..8b57f68e7 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 } }; diff --git a/app/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 81baa53fc..19d3d7a36 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -313,6 +313,10 @@ const schema: PrepareSchema = { wrongNetwork: 'Wrong network', wrongFrom: 'Wrong sender', invalidFrom: 'Invalid sender address', + noConnection: 'App is not connected', + expired: 'Request expired', + 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', diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index b2a4b99f6..7eb6d09f5 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -312,7 +312,11 @@ const schema: PrepareSchema = { "groupTitle": "Запросы на подтверждение", "wrongNetwork": "Неверная сеть", "wrongFrom": "Неверный адрес отправителя", - "invalidFrom": "Невалидный адрес отправителя" + "invalidFrom": "Невалидный адрес отправителя", + "noConnection": "Приложение не подключено", + "expired": "Запрос истек", + "failedToReport": "Транзакция отправлена, но не удалось ответить приложению", + "failedToReportCanceled": "Транзакция отменена, но не удалось ответить приложению" }, "signatureRequest": { "title": "Запрос на подпись", diff --git a/app/i18n/schema.ts b/app/i18n/schema.ts index c3ee5b67a..4e015a34d 100644 --- a/app/i18n/schema.ts +++ b/app/i18n/schema.ts @@ -315,6 +315,10 @@ export type LocalizationSchema = { wrongNetwork: string, wrongFrom: string, invalidFrom: string, + noConnection: string, + expired: string, + failedToReport: string, + failedToReportCanceled: string, }, signatureRequest: { title: string, diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index 904219c90..53bc092ab 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -2,15 +2,15 @@ import { t } from './i18n/t'; import { 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 { useCallback, useEffect, useRef } from 'react'; import { ToastDuration, useToaster } from './components/toast/ToastProvider'; import { jettonWalletAddressQueryFn, jettonWalletQueryFn } from './engine/hooks/jettons/usePrefetchHints'; import { useGlobalLoader } from './components/useGlobalLoader'; @@ -20,6 +20,15 @@ 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, SendTransactionError, 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'; const infoBackoff = createBackoff({ maxFailureCount: 10 }); @@ -35,6 +44,13 @@ export function useLinkNavigator( const toaster = useToaster(); const loader = useGlobalLoader(); + const [, updatePendingReuests] = useConnectPendingRequests(); + const pendingReqsUpdaterRef = useRef(updatePendingReuests); + + useEffect(() => { + pendingReqsUpdaterRef.current = updatePendingReuests; + }, [updatePendingReuests]); + const handler = useCallback(async (resolved: ResolvedUrl) => { if (resolved.type === 'transaction') { if (resolved.payload) { @@ -248,6 +264,196 @@ export function useLinkNavigator( hideloader(); } } + + if (resolved.type === 'tonconnect-request') { + const query = resolved.query; + 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)) { + throw Error('Invalid request'); + } + + 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', + duration: ToastDuration.LONG + }); + } + // 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); + } + } + } + }, [selected, updateAppState]); return handler; diff --git a/app/utils/resolveUrl.ts b/app/utils/resolveUrl.ts index 3443b0638..766b1947e 100644 --- a/app/utils/resolveUrl.ts +++ b/app/utils/resolveUrl.ts @@ -45,7 +45,7 @@ export type ResolvedUrl = { type: 'tonconnect', query: ConnectQrQuery } | { - type: 'tonconnect-tx', + type: 'tonconnect-request', query: ConnectPushQuery } | { type: 'tx', @@ -346,7 +346,7 @@ export function resolveUrl(src: string, testOnly: boolean): ResolvedUrl | null { // Tonconnect if (url.protocol.toLowerCase() === 'tc:') { if ( - url.host === 'sendTransaction' + url.host === 'sendtransaction' && !!url.query.message && !!url.query.from && !!url.query.validUntil @@ -357,7 +357,7 @@ export function resolveUrl(src: string, testOnly: boolean): ResolvedUrl | null { const to = decodeURIComponent(url.query.to); const message = decodeURIComponent(url.query.message); return { - type: 'tonconnect-tx', + type: 'tonconnect-request', query: { validUntil, from, to, message } }; } else if (!!url.query.r && !!url.query.v && !!url.query.id) { From ae9bd49dc1ab5ec433975c4d5eff4850a00ab392 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 25 Jun 2024 19:11:56 +0300 Subject: [PATCH 04/54] fix: navigation and watcher operner --- app/engine/tonconnectWatcher.ts | 2 -- app/fragments/HomeFragment.tsx | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/tonconnectWatcher.ts b/app/engine/tonconnectWatcher.ts index af487bcdd..5aaacc13f 100644 --- a/app/engine/tonconnectWatcher.ts +++ b/app/engine/tonconnectWatcher.ts @@ -63,8 +63,6 @@ export function useTonconnectWatcher() { } }); - watcher.open(); - return () => { if (watcher) { watcher.removeAllEventListeners(); diff --git a/app/fragments/HomeFragment.tsx b/app/fragments/HomeFragment.tsx index 8b57f68e7..5ab498a00 100644 --- a/app/fragments/HomeFragment.tsx +++ b/app/fragments/HomeFragment.tsx @@ -166,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); } }, []); From ae4d7de8fe462d161a72c1d9440637d0ff0c2a4f Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 26 Jun 2024 10:58:07 +0300 Subject: [PATCH 05/54] fix: adding default dont show spam comments value migration --- app/Root.tsx | 3 +++ app/engine/hooks/spam/useDontShowComments.ts | 9 +++------ app/engine/state/spam.ts | 21 ++++++++++++++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/Root.tsx b/app/Root.tsx index e60208c2e..180778591 100644 --- a/app/Root.tsx +++ b/app/Root.tsx @@ -15,8 +15,11 @@ 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'; const PERSISTANCE_VERSION = '23'; +// set default value for spam comments +migrateDontShowComments(); LogBox.ignoreAllLogs() 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/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(), From f52f04feb164c9aab7b5391e311efb2daac79352 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 26 Jun 2024 10:59:31 +0300 Subject: [PATCH 06/54] v2.3.8 --- VERSION_CODE | 2 +- ios/wallet/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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/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..e5475861f 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", From f201f606dccfd16a107eeb13df740cbabe1c5913 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 26 Jun 2024 11:19:14 +0300 Subject: [PATCH 07/54] fix: fixing account title & margins conditions --- app/components/products/HoldersAccounts.tsx | 4 ++++ app/components/products/HoldersProductComponent.tsx | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/components/products/HoldersAccounts.tsx b/app/components/products/HoldersAccounts.tsx index 3acbb2c9d..833e8604e 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 ( 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 }}> + Date: Wed, 26 Jun 2024 14:34:09 +0300 Subject: [PATCH 08/54] fix: fixing browser banners boxSize, image component, adding placeholders, param typo --- app/components/ConnectedAppButton.tsx | 2 +- app/components/ConnectionButton.tsx | 2 +- app/components/QRCode/QRCode.tsx | 2 +- app/components/WImage.tsx | 37 ++++++++++++------ app/components/browser/BrowserBanner.tsx | 28 +++++++++---- app/components/browser/BrowserBanners.tsx | 5 +-- .../products/HoldersAccountItem.tsx | 2 +- app/components/products/JettonIcon.tsx | 6 +-- .../products/SpecialJettonProduct.tsx | 2 +- app/components/staking/LiquidStakingPool.tsx | 2 +- app/components/staking/StakingPool.tsx | 2 +- .../components/review/ReportComponent.tsx | 2 +- .../components/review/ReviewComponent.tsx | 2 +- .../secure/components/TransferBatch.tsx | 6 +-- .../secure/dapps/DappAuthComponent.tsx | 2 +- .../staking/StakingPoolSelectorFragment.tsx | 2 +- app/fragments/wallet/ReceiveFragment.tsx | 2 +- .../wallet/products/ProductButton.tsx | 2 +- .../wallet/views/DappRequestButton.tsx | 2 +- assets/banners/banner-icon-placeholder.webp | Bin 0 -> 756 bytes assets/banners/banner-placeholder.webp | Bin 0 -> 6532 bytes 21 files changed, 68 insertions(+), 42 deletions(-) create mode 100644 assets/banners/banner-icon-placeholder.webp create mode 100644 assets/banners/banner-placeholder.webp 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 }}> } 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/browser/BrowserBanner.tsx b/app/components/browser/BrowserBanner.tsx index d8ba0d075..d715a8319 100644 --- a/app/components/browser/BrowserBanner.tsx +++ b/app/components/browser/BrowserBanner.tsx @@ -1,6 +1,6 @@ import { memo, useCallback } from "react"; import { BrowserBannerItem } from "./BrowserListings"; -import { View, Text, Image, Pressable } from "react-native"; +import { View, Text, Pressable } from "react-native"; import Animated, { Extrapolation, SharedValue, interpolate, useAnimatedStyle } from "react-native-reanimated"; import { ThemeType } from "../../engine/state/theme"; import { Typography } from "../styles"; @@ -8,6 +8,7 @@ import { Canvas, LinearGradient, Rect, vec } from "@shopify/react-native-skia"; import { TypedNavigation } from "../../utils/useTypedNavigation"; import { MixpanelEvent, trackEvent } from "../../analytics/mixpanel"; import { extractDomain } from "../../engine/utils/extractDomain"; +import { Image } from 'expo-image' export const BrowserBanner = memo(({ banner, @@ -111,10 +112,19 @@ export const BrowserBanner = memo(({ onPress={onPress} > {(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..43ce591de 100644 --- a/app/components/browser/BrowserBanners.tsx +++ b/app/components/browser/BrowserBanners.tsx @@ -17,7 +17,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; @@ -72,9 +72,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/products/HoldersAccountItem.tsx b/app/components/products/HoldersAccountItem.tsx index aef71fa21..ec0ab9f60 100644 --- a/app/components/products/HoldersAccountItem.tsx +++ b/app/components/products/HoldersAccountItem.tsx @@ -146,7 +146,7 @@ export const HoldersAccountItem = memo((props: { diff --git a/app/components/products/JettonIcon.tsx b/app/components/products/JettonIcon.tsx index 28219b0d2..22e825515 100644 --- a/app/components/products/JettonIcon.tsx +++ b/app/components/products/JettonIcon.tsx @@ -36,7 +36,7 @@ export const JettonIcon = memo(({ ) : ( @@ -65,7 +65,7 @@ export const JettonIcon = memo(({ ) : ( @@ -100,7 +100,7 @@ export const JettonIcon = memo(({ {isKnown ? ( 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(({ 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/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 }) => { > { > { }} /> @@ -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/dapps/DappAuthComponent.tsx b/app/fragments/secure/dapps/DappAuthComponent.tsx index 3fc2202ec..82e17bbb3 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', }}> )} 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/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) => { bqwk0fz*XfE<4Wbk_>5Z;5|>pON!L^nU_? z+~O;WFYAb&Mfnzhsi^M(H~nU)2|;_!Ab(*ndNBw;4EAFDZ~SlkZ~Xt)_{(7VVh~;# zbWRLXB?b00_21r7K$=PuMHRn?8AF zr6Y)dj1#~ojoW{%TwVN5BrgD$o>f%tnx6|R1=-LLp;3$833RoBdM;fE~twR0040RStBCA93CK{tS&Eu2mruQ z);TP5UBOa>L=2>r0Z7AGPp)PY+*tO@C))Gjoj6f6#KE1NW|j5GDdi^RRbt5|9KHG} zb^LW(Cf_OrNnh^r!ZYp8>)iio)oI_}3L;R`fyMRqv7(4FQFMay$xWtDJ@#Koh{TX} z$ln+<=Oy9`|BPC1bg|?{%MJ7PH)$F!?(x1`E?MAt84xBbMy-K7a;t)@*sjeo=|{Eq zq<%*7e@*-0pw&WlLYbPIIz2eksJ%cIW&1;C$1@4-Rk}C3Q@Kd{r70`SLnfC`O+3hK zg0?`ZAb*0E;W;u=DmeN`DbGe(?gnZCEGl@^5G1!`wC!Oaw;;qPibp03MC>ih4!@36 zT<)~%h4zYzMJkWd;^!%B&;^-IZvzZuie+cMPI8+vpQ!u_NfA%{0E z(y#b+XJ(n>hUhIV&we<_9ae!j-_=(Rjmaw}Pzm`Xgt*M+QW3y*hE!%VE9~q9^`uAF z*(Qk6nGyaT0QKxZ*X<6btSWga3uW(Z7q)n0q6ZC-|6uNCXO+gXsglO71xn-93YsC6 z$79-o>MNNzOruu2le;>TksE5^>R+~qi(#!yFXv*g*ZXz<|35CH3o83>?ko5v!-L() zcc=z$CniduuH==jWcFYV3|S8Me1JaXB-+2siEQ);c5^KiY7c0KMdxoth{P^g^|f(0c~-ODXvr8gpBHT~?^akQBzJTg&uDGGcHQ2d(^p1+Hf zM41{kUJ=*BVH@3tfS)oc=!_$mnO9YTK>97LFT0EQK{ zGjni3^B=jqE?C@1WwJXY~>66GE~I1)_~|?Psh5IJ*4I z@zalI_~%hsyPB#cYsMDu&nPFPNyZ?f`04&*_I2%H*wQYW)gR{dBxy9GS5L6d+|C1} zTD;wD_>YwjwmFln90WestkYa>`OPQwx@U8wiGGYK^Z*enPon_-^%q)PA^>3G8IT3X z+JtBUFB&hOs|+nGpwHDx{pf^`X6p!h%RbFlg{>$X(ZHUP z)kN(Xb8?jFK!Qd#4j~+yQqXC$zlzNj&*Qv$B)S=rUPVc1N5{k%%Axc#2L}GWZ#?X? zft{NY)g=|wdtHW`gWzCs|G%>B#WQnNUH`B;-!biPIi)c%1SJlG!d^^Tveo*99lr8R zR^|#UD=fEAlhuf3=qQfaSXUY;M>f-e2YcvD+vJN(Ga5jHlptrfb*i^@+{WM*e|%0F z+ZAyCr$Dk@tyVYwwok@lH&W}A6Y3JzzPOH}Oj=~Rx`yO>465r+zuV7Ltc)Hj>ken$ zZw>xn6}Jrt&NtUw)F+`2X~RJGmoANfB9G_Gbs9#FJCIkN7}MJ+SO7orvJ(Os{iM$te@oR94v4-s?_6IxEQK;bMLN~L#zXlImdcq%|~Rr z=U?xC@MNc@WQEYDMi33n=O~!gWu&qWM*VgeaqjdYKzrEe&0KXzO!YH|bWe@GWwCn( za;9kfaz`w7m9j6U7O1Sc9%d?VrvPd$*+TWTjA*RNS2{l*JJx1`L9aym8p8^J;j-tuPI_dIV*+yxxov$T1+lEGuvi9#rNR+|kJx8+y;^ zw<5P&0QOv~7ke%vttpZ3cvr}WU<)Et!+`tfo2$sU22y;@vkRcTS%l?UyEaCMt)SwD zQ-)0FPiU0|Uv>6FXR}R4=KU^XjIvM_wVbi3Jx50DAZRHXeKGq;{&P|!|fjvX< zIOFM*B;E0A4iA$Uw5c2f8XXFpPs@e! z?W+WO%zM2Tl_DX(1m$&&Pg+Hf#V@RqfhV#u6j=+ujW0CLe_V<1JW2X^x>O{A;zi%_ zA5o?A-*X&WYFlh|*o0BFFn&&Ej;cf+dgje}DteuhL+<#2`2&UZ^N@d;EjVu@YCOaj z{+=`%N7$;XP@gS9hR#_KDj`F&EN&OhutaH|!aW|0B~z8@Wwi38<}?v0H0v{__(>W| zl9mxZY$vuhw1SiVCT7gxGj+8KBdBKwE2UGR$k3B$mpE(01=ljwTjhAIdgx>(BzpP) zp?g?Ok^<=mf;FOmm!zr3hF=og72+7W%}gl$q7nsO%(;lwVbgt;wOm3rWdzODjdy;9 z029K$Le9$&L~k#!xV~|@a#@wPdY-o7ew8xg&75ATe7bRX!FyzibSStV622a>&U-`8 zWlQM;yHmLM6+2TQl36BIOg6L#T!b2D?qy$WuH2@$=$w&z;uTvAb<-}IqFAvOthuRW zphv0Sw_05ri116hN|%i~_`(zWGduI_O=6fTb%q7wbWcGuF|>qcS^e=#>Hmv-j!h}> zD&a&@q&-JGN+)*fxF$?(`Ia(-X7^l5egAvyt98-9&k~4d`T2PsWH+C8ZO7@ zPUWvm$yj9(mut2g%cI7xz>Bl;? z9$~e>Ok|6i!Q`I#6^rlo7}u@`_49k^^1a35TYlMxPi{Y#KcSw(DqlLA$mALdnXVX- zqLL&L5Rq)>759sc@eT#nYu!1$IdP3oX4DgEwkeDlKL|#7;T`assQeiSL3VS?t6+)` zv^wgZ8{SE_%*rrTk;WcmnJ@D=Ja7t_oMVxGQNGgr!G5h@PVcOsa<;v#eek;{yKT6gbEH)!g91^cW6T!X-eVoG8|5G=CmBi zPT|>8Q%V!zv>e{n!qKH`L%oTN)o0~zdm%1*n>jx##DV*@!AiNseTJafq2-6h&KDV0 zTRF#av(gsCFJf-c0|30i4&8I!CQItXo@y&_0xDs{vEGjeZnHA5&fyoadx~}o(44DV zH#Uu8?`os!3oXO)G!D$5hgY8M^sRy4GYvT3@At-|VW|=1my3vG=Y+~)+`xQMEk1^O zQ5*zcRNw1Pd%&2YibQM4WZR$7pXsy8OdXx%m~ou*VmBx`zYZzC{HQ@&h*?+-A1g?R zK+sU(I*Dpz`X$uugBdKBC202b-6!jYWCUu1_dXgthTAr(GKz>M3y{Mh?$3fpT36^^ zlTPnvtRToj2jho`(wpC?Gm+Ls89J$sD~0^{;a;OD)5Mz4RlSX3!KmDrtu7qYquaZk zq>K2khzvpatT0n}iCnXyY&X$JI)r&~ZW!c+6uFmF(fDfj&(QZbBqFkSXA+9xs|oC@ zGP{scVP7%W{^3#6I%GU2r(yBMX4={Zk`tts5pVVji=3Mec5%(BwBbg=2|H=%QJ{b= z@lpE0bi!YB;1FQuC3yFgUX}h@V*%nNWTC`mnUz_3<3V5mIdPOjrl}Dg7eG*Qr>gAF zvfiFKzh4$%Vkf;yF2^5wMUC$-&Oe5 zrns=cX1s;300)}iMcsZZ$m44WFCliGXJE5~K@AfiE|WeE1dio49dGQu(L4z-Nz(Ve zCITgfYUyzD9R*T8s9MEv#?i_`SZh|HZfc=rFbS+Y0~e z>gGaG{uIj^^ygIBfKLq71?c^)E6MT>FrF~tq>J2ti(7b8XAOMqUn^aZdgY~NcHiUV zkXBNs6rmcEZHBYyi7Y+sPb(Y#kg*#B z_>}uKY~*CHwcVgcdLL|e!x*V^llg-n>0AcjeS`6z4!g0q@N^1A{H(Mu3m8iLO+!B2 z&$=nReOhEGl5gK&mWQ9H05KPjCkBs#pyEOMoY^~u*y|5TqO7aXIGa#M;cq{nKZc^( z-ZeQltGCe-qm_tFYSgdmY73NGVxjkB$!Ys*M^=9dyS ziCxtkQmaB2iV|+F0mY}D6Iwz+Ob-a{liACn20ucJXsmpfY#&xa>47v!fD9v+JITDl zB9qf*y^q_93lI zdtOLgxV&7S=Ifwq5<{&w0ds=&d^_g`km zkx@w#;xB?1)v!0`Q?pVgiSNcv=;kovgwMXPp@~yMI}WP$=C>hV{%K^~gYAHZ9&E~= zTB2!qp_47t{QGP=38S9XhafytlTAs?$L14UE3}SW>`_l)$|llF~t9lBf$L z8#yz^MCJVX83|+wc!;784$+-l?=0y)xB4#T%+oBm?_zpu<(!{wQR2BMmox&Kh18+C2Fk$$kN7g_r>Pi z8aCqU_byAjzd0QU95dDL2DsD2_rHdKid8Z2kErd18=M*Z|{IQcH4o;ddzLCfz=yA~DPz6^<>W4&x_RNRUFDcI5Lu)x8! z)SZeq!u8%CuARPaa;!D!UGZBfg*x6`AGyiw7EOBrkF`@;*D?XBC}URFz*^V>Sgt1z z7uy_ToD53-nK}f-zxGmq{Og%qDvR4UN_|3HLDkvpl56qAR?U8acblBzY=G-0x{Xk9 z0Z*xqwQAzZ5$Il0Um;v}Rv0tIEVgFd0&>{XpWdC~+Y4YQA4Es2N6s;-;EN@m=EEF^ zoJ4W*3@0g(c~TZPmBUxt9uP(t0DIGx=6q4($}wODDb6( zHT7a4)OH!X$ftObC=>)GPSgIoRDPJrtzYRn3m29)26M~7=5|x-y@s`aZrSqDfEJbZ z;jd$p>^Obs+W2XGvDBHZLrGG)kfk3{d@42e$3{;tb;S&ARhaNj_g^e|yQ{G06Ln>H zV(7O}dJSDCZ}(q0z8ROlLkHLj>=zhoKm=tikVtcfKzES#lC;)^;IQDE zGB7RK5Di&T)Hzh*!6;|#^?rvK1;rjI_rZ2OF z4^ODESazi+edNlA&Ul8$iGo+&EOp&{2CDGme>vKHrcRmy+&~q_9(-0}Y#&ToF|D7U zT!QS(V=}oX%!>B9^a-&K2wsi5y8dSF zO&3!G&YiH&ty?*@h!wFOZMp7- zt6;riOQ%r&DBR0%54=)q@U9H$Q4QX=nq4najilh;Y(zZCU$-7Sl4!e6J2Pvq+L_+` z7%cmrzINgzMSTJj6knVR?_nEHg)aEENmpfiJ_#|)bprg=q5Nc32I&e`>teTCH;z3> z4n*6ocOcyGK-P@ut{^I%?+(}j?Ck>1Q^cL&)6zZp@9}y*EHe{YXO}gC`H`)DKCkb` zF(Hz0*AmFG>bSfN7L@9#di;9#s1coGvlvFY)gw9pt@Z0m-yrDm!G`?eb=}mjwY777 z>Ki^Yeb`+tk*?u;HC#+M(h>(@i2cfLBuu^ssqG=SEbexzzml1=mOzSvfzpIgY_H`=Zv#fa04Y1?@i-0D#y&LEx-z zZK&e@ndF!ks7gqjTbs{Kge;y!6k!vuI8czTf&b-y0{riR&vPr{7kz#>78c~Coz&na zqr(t-xpfR$J<8pQt-AUT1N3@;uy=gr9C0eOO3SFw?)tFQX8g<&e@lOVmz~l@`yU6- zzMZT~9#|wP&*|G`!{+|zTEq4yYniO_KLGIlRD3jz2*=oYv#0y^fOPD4{(V*(CMgtu z;y(l~=fmHM8CwCT@2EeESk}(kQ1@pZbS_&^=hFW}0BOUgoVz`%b392vHKek@tx$qw zov7OWhcEvTq(07lYASZaC!5u+gX})kZ2C>^Y-a*)NMEKp#whJ8*k@ntgfct9+AE^St{I{g%+IzWdB0oK%Z`wgzxo#QY z{kl@>fe-4JG`eR?W(eXC$D?`u$WECSKycS`rs6zHTJ!j8;#?tieJ!ibX5TfTag?43 zZm#_uSjKJhE+#R3Wy*o(hD`>pa-7NeLdNYEU~St0hZCVkJ?OEb zndV|+->y<*97imy7lld)$RFno09eM?^Sw3dF3FdO8GUqIuLF&T)UaG?xYhBzD-GKh zvmx)rQT3+k^54L?XukP_?K$2!>5mO$5oj#E9oLY@J7*=BMAqKId!1(9UvOHh@wCCc z;+o%H67^g#cV3rt_-;&a6YVh~vHQT=Ay?jLZfr_OwQ7(i8Lb9BPga0KRmsN9G5_1f z5cm+9`6;vK>SkX5%V}4r>5#X{I^RG>ijyL2BWN|bdlXcT`sc)7d%N0mi&9M*EW~V3`969y;GYyIJD`lCySoXI|mFZ zW@~IVkHcN@>uxLLEVacI1`iorHTTxtw} Mog(hL;kbbR13S;MV*mgE literal 0 HcmV?d00001 From 880c726abc986b11d28791f449b941ce327d8e47 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 27 Jun 2024 12:00:27 +0300 Subject: [PATCH 09/54] wip: adding spam comment detection, fixing verif avatars --- app/components/avatar/Avatar.tsx | 4 +- app/components/avatar/ForcedAvatar.tsx | 88 +++++++++--- .../avatar/PendingTransactionAvatar.tsx | 2 +- .../secure/components/TransferSingleView.tsx | 8 +- .../wallet/PendingTxPreviewFragment.tsx | 10 +- .../wallet/TransactionPreviewFragment.tsx | 37 +++-- .../wallet/views/TransactionView.tsx | 24 ++-- app/fragments/wallet/views/TxAvatar.tsx | 16 ++- app/utils/spam/isTxSPAM.ts | 93 +++++++++++++ .../spam/matchesWeightedKeywords.spec.ts | 48 +++++++ app/utils/spam/spamKeywords.ts | 126 ++++++++++++++++++ 11 files changed, 405 insertions(+), 51 deletions(-) create mode 100644 app/utils/spam/isTxSPAM.ts create mode 100644 app/utils/spam/matchesWeightedKeywords.spec.ts create mode 100644 app/utils/spam/spamKeywords.ts 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 ? ( - + ) : ( { if (forceAvatar) { - return (); + return ( + + ); } return ( 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 ? ( - + ) : ( { 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/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/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 From 85f08e7943f16d57021092c5f6f81329e7e625ae Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 28 Jun 2024 14:33:25 +0300 Subject: [PATCH 10/54] wip: adding background session watcher to lock app with auth on timeout & Blur content preview (iOS only) --- app/Navigation.tsx | 31 +++- app/Root.tsx | 5 +- app/components/AppBlurContext.tsx | 58 +++++++ app/components/SessionWatcher.tsx | 61 +++++++ app/components/secure/AuthWalletKeys.tsx | 13 +- app/fragments/AppAuthFragment.tsx | 201 +++++++++++++++++++++++ app/fragments/AppStartAuthFragment.tsx | 141 ---------------- ios/Podfile.lock | 4 +- package.json | 2 +- yarn.lock | 8 +- 10 files changed, 370 insertions(+), 154 deletions(-) create mode 100644 app/components/AppBlurContext.tsx create mode 100644 app/components/SessionWatcher.tsx create mode 100644 app/fragments/AppAuthFragment.tsx delete mode 100644 app/fragments/AppStartAuthFragment.tsx diff --git a/app/Navigation.tsx b/app/Navigation.tsx index a70c15101..e5ceba644 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'; @@ -95,6 +95,7 @@ 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'; const Stack = createNativeStackNavigator(); Stack.Navigator.displayName = 'MainStack'; @@ -179,6 +180,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 ( @@ -306,7 +331,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 }), ]; @@ -440,6 +466,7 @@ export const Navigation = memo(() => { + ); diff --git a/app/Root.tsx b/app/Root.tsx index 180778591..270eb6aec 100644 --- a/app/Root.tsx +++ b/app/Root.tsx @@ -16,6 +16,7 @@ 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 @@ -58,7 +59,9 @@ export const Root = memo(() => { - + + + 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/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/secure/AuthWalletKeys.tsx b/app/components/secure/AuthWalletKeys.tsx index 4f2344d2c..46c5e5c0e 100644 --- a/app/components/secure/AuthWalletKeys.tsx +++ b/app/components/secure/AuthWalletKeys.tsx @@ -1,4 +1,4 @@ -import React, { createContext, memo, useCallback, useContext, useEffect, useState } from 'react'; +import React, { createContext, memo, MutableRefObject, useCallback, useContext, useEffect, useRef, useState } from 'react'; import Animated, { BaseAnimationBuilder, EntryExitAnimationFunction, FadeOutUp, SlideInDown } from 'react-native-reanimated'; import { Alert, Platform, StyleProp, ViewStyle } from 'react-native'; import { SecureAuthenticationCancelledError, WalletKeys, loadWalletKeys } from '../../storage/walletKeys'; @@ -17,11 +17,12 @@ import { useBiometricsState, useSetBiometricsState, useTheme } from '../../engin import { useLogoutAndReset } from '../../engine/hooks/accounts/useLogoutAndReset'; import { CloseButton } from '../navigation/CloseButton'; import { SelectedAccount } from '../../engine/types'; +import { useAppBlur } from '../AppBlurContext'; export const lastAuthKey = 'lastAuthenticationAt'; // Save last successful auth time -function updateLastAuthTimestamp() { +export function updateLastAuthTimestamp() { storage.set(lastAuthKey, Date.now()); } @@ -61,7 +62,7 @@ export type AuthProps = 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 +129,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(); @@ -156,6 +158,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 +245,8 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => // Overwise, premissionsRes: 'biometrics-cancelled' |'none' | 'use-passcode' // -> Perform fallback to passcode } + } finally { + setAuthInProgress(false); } } @@ -251,9 +256,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 } }); }); } diff --git a/app/fragments/AppAuthFragment.tsx b/app/fragments/AppAuthFragment.tsx new file mode 100644 index 000000000..c1cad14a2 --- /dev/null +++ b/app/fragments/AppAuthFragment.tsx @@ -0,0 +1,201 @@ +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 { warn } from "../utils/log"; +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') { + console.log('App unblured, current'); + setBlur(false); + } + } + }); + + return () => { + // Don't forget to remove the listener when the component is unmounted + transitionEndListener.remove(); + subscription.remove(); + }; + }, []); + + 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 + ? async () => { + 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); + } + } + : undefined} + onLogoutAndReset={ + (attempts > 0 + && attempts % 5 === 0 + ) + ? fullResetActionSheet + : undefined + } + passcodeLength={storage.getNumber(passcodeLengthKey) ?? 6} + onRetryBiometrics={ + (useBiometrics) + ? async () => { + 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') }] + ); + } else { + Alert.alert(t('secure.onBiometricsError')); + warn('Failed to load wallet keys'); + } + } finally { + setAuthInProgress?.(true); + } + } + : 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/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/package.json b/package.json index e5475861f..6de5fd62a 100644 --- a/package.json +++ b/package.json @@ -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" From 68c153b20a1a811a05475bb4146ee22e5a010af3 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 28 Jun 2024 14:44:57 +0300 Subject: [PATCH 11/54] wip: adding lock app with auth bridge method --- app/components/webview/DAppWebView.tsx | 23 ++++++++++++++++++- .../components/inject/createInjectSource.ts | 23 +++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index e028befb3..7fac765de 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); @@ -179,6 +181,25 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar // Dispatch response dispatchAuthResponse(ref as RefObject, { authenicated, lastAuthTime }); })(); + } else if (method === 'lockAppWithAuth') { + (async () => { + let authenicated = false; + let lastAuthTime: number | undefined; + // wait for auth to complete then set lockApp tag + try { + await authContext.authenticate(); + authenicated = true; + lastAuthTime = getLastAuthTimestamp(); + } catch { + warn('Failed to authenticate'); + } + + if (authenicated) { + setLockAppWithAuth(true); + } + + dispatchLockAppWithAuthResponse(ref as RefObject, { authenicated, lastAuthTime }); + })(); } return; diff --git a/app/fragments/apps/components/inject/createInjectSource.ts b/app/fragments/apps/components/inject/createInjectSource.ts index a6e98254d..871f562b6 100644 --- a/app/fragments/apps/components/inject/createInjectSource.ts +++ b/app/fragments/apps/components/inject/createInjectSource.ts @@ -157,8 +157,8 @@ 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) => { @@ -167,11 +167,22 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean return; } - window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.authenticate' } })); inProgress = true; currentCallback = callback; + window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.authenticate' } })); }; + cosnt lockAppWithAuth = (callback) => { + if (inProgress) { + callback({ authenicated: 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) { @@ -189,6 +200,9 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean if (!!ev.data.lastAuthTime && ev.data.authenicated === true) { params.lastAuthTime = ev.data.lastAuthTime; } + if (!!ev.data.isLockedByAuth && ev.data.isLockedByAuth === true) { + params.isLockedByAuth = true; + } currentCallback({ authenicated: ev.data.authenicated }); } currentCallback = null; @@ -316,6 +330,11 @@ export function dispatchAuthResponse(webRef: React.RefObject, data: { a webRef.current?.injectJavaScript(injectedMessage); } +export function dispatchLockAppWithAuthResponse(webRef: React.RefObject, data: { authenicated: boolean, lastAuthTime?: number }) { + let injectedMessage = `window['tonhub-auth'].__response(${JSON.stringify({ data: { ...data, isLockedByAuth: data.authenicated } })}); true;`; + webRef.current?.injectJavaScript(injectedMessage); +} + export function dispatchMainButtonResponse(webRef: React.RefObject) { let injectedMessage = `window['main-button'].__response(); true;`; webRef.current?.injectJavaScript(injectedMessage); From b3d6ba851a8e5ac385eb4f721267a314b4a895cc Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 28 Jun 2024 15:51:52 +0300 Subject: [PATCH 12/54] fix: injection code & adding locked check --- app/components/webview/DAppWebView.tsx | 10 ++++++++++ .../apps/components/inject/createInjectSource.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index 7fac765de..05bf21d80 100644 --- a/app/components/webview/DAppWebView.tsx +++ b/app/components/webview/DAppWebView.tsx @@ -183,6 +183,16 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar })(); } else if (method === 'lockAppWithAuth') { (async () => { + + const isAlreadyLocked = getLockAppWithAuthState(); + if (isAlreadyLocked) { + dispatchLockAppWithAuthResponse( + ref as RefObject, + { authenicated: true, lastAuthTime: getLastAuthTimestamp() } + ); + return; + } + let authenicated = false; let lastAuthTime: number | undefined; // wait for auth to complete then set lockApp tag diff --git a/app/fragments/apps/components/inject/createInjectSource.ts b/app/fragments/apps/components/inject/createInjectSource.ts index 871f562b6..ea1e9dee4 100644 --- a/app/fragments/apps/components/inject/createInjectSource.ts +++ b/app/fragments/apps/components/inject/createInjectSource.ts @@ -172,7 +172,7 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.authenticate' } })); }; - cosnt lockAppWithAuth = (callback) => { + const lockAppWithAuth = (callback) => { if (inProgress) { callback({ authenicated: false, erorr: 'auth.inProgress' }); return; @@ -209,7 +209,7 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean } } - const obj = { __AUTH_AVAILIBLE, params, authenicate, getLastAuthTime, __response }; + const obj = { __AUTH_AVAILIBLE, params, authenicate, getLastAuthTime, lockAppWithAuth, __response }; Object.freeze(obj); return obj; })(); From 9b1d4984aec50c93afa0e2e27731503d3195d97a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 28 Jun 2024 16:46:21 +0300 Subject: [PATCH 13/54] add: lock turning off app auth if user has holders products --- app/engine/hooks/holders/index.ts | 3 ++- app/engine/hooks/holders/useHasHoldersProducts.ts | 15 +++++++++++++++ app/fragments/SecurityFragment.tsx | 7 ++++++- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 app/engine/hooks/holders/useHasHoldersProducts.ts 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..a8db60bd5 --- /dev/null +++ b/app/engine/hooks/holders/useHasHoldersProducts.ts @@ -0,0 +1,15 @@ +import { Address } from "@ton/core"; +import { useHoldersAccounts } from ".."; + +export function useHasHoldersProducts(address?: string | Address) { + const accs = useHoldersAccounts(address).data; + + if (!accs) { + return false; + } + + const accounts = accs.accounts.length; + const cards = accs?.prepaidCards?.length || 0; + + return accounts + cards > 0; +} \ No newline at end of file diff --git a/app/fragments/SecurityFragment.tsx b/app/fragments/SecurityFragment.tsx index 007c92275..91fdfd6d0 100644 --- a/app/fragments/SecurityFragment.tsx +++ b/app/fragments/SecurityFragment.tsx @@ -6,7 +6,7 @@ import { fragment } from "../fragment" import { t } from "../i18n/t" import { BiometricsState, PasscodeState } from "../storage/secureStorage" import { useTypedNavigation } from "../utils/useTypedNavigation" -import { useTheme } from '../engine/hooks'; +import { useHasHoldersProducts, useSelectedAccount, useTheme } from '../engine/hooks'; import { useEffect, useMemo, useState } from "react" import { DeviceEncryption, getDeviceEncryption } from "../storage/getDeviceEncryption" import { Ionicons } from '@expo/vector-icons'; @@ -24,6 +24,7 @@ import TouchAndroid from '@assets/ic_touch_and.svg'; import FaceIos from '@assets/ic_face_id.svg'; export const SecurityFragment = fragment(() => { + const selectedAccount = useSelectedAccount(); const safeArea = useSafeAreaInsets(); const navigation = useTypedNavigation(); const authContext = useKeysAuth(); @@ -31,9 +32,12 @@ export const SecurityFragment = fragment(() => { const passcodeState = usePasscodeState(); const biometricsState = useBiometricsState(); const setBiometricsState = useSetBiometricsState(); + const hasHoldersProducts = useHasHoldersProducts(selectedAccount?.address ?? ''); const [deviceEncryption, setDeviceEncryption] = useState(); const [lockAppWithAuthState, setLockAppWithAuthState] = useLockAppWithAuthState(); + const canToggleAppAuth = !(hasHoldersProducts && lockAppWithAuthState); + const biometricsProps = useMemo(() => { if (passcodeState !== PasscodeState.Set) { return null; @@ -218,6 +222,7 @@ export const SecurityFragment = fragment(() => { } })(); }} + disabled={!canToggleAppAuth} /> From 6671e8ae1b809d4c9861966104845c749572bbfd Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 1 Jul 2024 13:04:57 +0300 Subject: [PATCH 14/54] fix: fixing param --- app/engine/hooks/holders/useHasHoldersProducts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/hooks/holders/useHasHoldersProducts.ts b/app/engine/hooks/holders/useHasHoldersProducts.ts index a8db60bd5..51e6e20ac 100644 --- a/app/engine/hooks/holders/useHasHoldersProducts.ts +++ b/app/engine/hooks/holders/useHasHoldersProducts.ts @@ -1,7 +1,7 @@ import { Address } from "@ton/core"; import { useHoldersAccounts } from ".."; -export function useHasHoldersProducts(address?: string | Address) { +export function useHasHoldersProducts(address: string | Address) { const accs = useHoldersAccounts(address).data; if (!accs) { From 724215506134f8d8a2da62c9ddf6d7d319fc77e0 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 1 Jul 2024 13:23:09 +0300 Subject: [PATCH 15/54] fix: fixing retry biometrics callback --- app/components/secure/AuthWalletKeys.tsx | 47 +++++++++------- app/fragments/AppAuthFragment.tsx | 70 ++++++++---------------- 2 files changed, 51 insertions(+), 66 deletions(-) diff --git a/app/components/secure/AuthWalletKeys.tsx b/app/components/secure/AuthWalletKeys.tsx index 46c5e5c0e..2d2afb24a 100644 --- a/app/components/secure/AuthWalletKeys.tsx +++ b/app/components/secure/AuthWalletKeys.tsx @@ -328,6 +328,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} @@ -386,26 +412,7 @@ 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 && ( { }; }, []); + 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 ( { throw Error('Failed to load keys'); } }} - onMount={useBiometrics - ? async () => { - 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); - } - } - : undefined} + onMount={useBiometrics ? authenticateWithBiometrics : undefined} onLogoutAndReset={ (attempts > 0 && attempts % 5 === 0 @@ -169,31 +171,7 @@ export const AppAuthFragment = fragment(() => { : undefined } passcodeLength={storage.getNumber(passcodeLengthKey) ?? 6} - onRetryBiometrics={ - (useBiometrics) - ? async () => { - 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') }] - ); - } else { - Alert.alert(t('secure.onBiometricsError')); - warn('Failed to load wallet keys'); - } - } finally { - setAuthInProgress?.(true); - } - } - : undefined - } + onRetryBiometrics={useBiometrics ? authenticateWithBiometrics : undefined} /> )} From 04199a8cc498ddec2702af3096cdf3efb6637f44 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 1 Jul 2024 18:58:10 +0300 Subject: [PATCH 16/54] wip: adding tonconnect requests validations, fixing looped ret start on Android, adding answer delivery check --- app/engine/hooks/dapps/useConnectCallback.ts | 25 +--- .../hooks/dapps/usePrepareConnectRequest.ts | 112 +++++++++++---- .../secure/dapps/DappAuthComponent.tsx | 8 +- .../dapps/TonConnectAuthenticateFragment.tsx | 127 +++++++++--------- .../wallet/views/TonConnectRequestButton.tsx | 100 +++++--------- app/i18n/i18n_en.ts | 1 + app/i18n/i18n_ru.ts | 1 + app/i18n/schema.ts | 1 + app/useLinkNavigator.ts | 3 +- 9 files changed, 195 insertions(+), 183 deletions(-) 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/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/fragments/secure/dapps/DappAuthComponent.tsx b/app/fragments/secure/dapps/DappAuthComponent.tsx index 82e17bbb3..aae7769b5 100644 --- a/app/fragments/secure/dapps/DappAuthComponent.tsx +++ b/app/fragments/secure/dapps/DappAuthComponent.tsx @@ -374,16 +374,16 @@ export const DappAuthComponent = memo(({ {t('auth.apps.connectionSecureDescription')} - + { @@ -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); @@ -124,11 +124,58 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr }, []); // Approve - let active = useRef(true); + const active = useRef(true); useEffect(() => { - return () => { active.current = false; }; + return () => { + 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,31 +306,13 @@ 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'), @@ -291,9 +320,11 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr connectProps.callback({ ok: true, replyItems }); setTimeout(() => { - navigation.goBack(); + navigate.current(); }, 50); - } + }, + duration: ToastDuration.SHORT, + marginBottom: toastMargin }); return; } @@ -306,7 +337,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 +346,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 +382,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/wallet/views/TonConnectRequestButton.tsx b/app/fragments/wallet/views/TonConnectRequestButton.tsx index cb75d68c5..e09392e1b 100644 --- a/app/fragments/wallet/views/TonConnectRequestButton.tsx +++ b/app/fragments/wallet/views/TonConnectRequestButton.tsx @@ -5,13 +5,10 @@ import { t } from "../../../i18n/t"; import { extractDomain } from "../../../engine/utils/extractDomain"; import { SendTransactionRequest } from "../../../engine/tonconnect/types"; import { DappRequestButton } from "./DappRequestButton"; -import { PreparedConnectRequest } from "../../../engine/hooks/dapps/usePrepareConnectRequest"; -import { Toaster, useToaster } from "../../../components/toast/ToastProvider"; +import { ToastDuration, useToaster } from "../../../components/toast/ToastProvider"; import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import { Platform } from "react-native"; -import { Address } from "@ton/core"; -import { getCurrentAddress } from "../../../storage/appState"; -import { CHAIN } from "@tonconnect/protocol"; +import { Cell } from "@ton/core"; type TonConnectRequestButtonProps = { request: SendTransactionRequest, @@ -19,59 +16,17 @@ type TonConnectRequestButtonProps = { isTestnet: boolean } -function checkNetworkAndFrom( - params: { request: PreparedConnectRequest, isTestnet: boolean, }, - toaster: Toaster, - toastProps: { marginBottom: number } -) { - const { request, isTestnet } = params; - const toasterErrorProps: { type: 'error', marginBottom: number } = { type: 'error', marginBottom: toastProps.marginBottom }; - - if (!!request.network) { - const walletNetwork = isTestnet ? CHAIN.TESTNET : CHAIN.MAINNET; - if (request.network !== walletNetwork) { - toaster.show({ - ...toasterErrorProps, - message: t('products.transactionRequest.wrongNetwork'), - }); - - return false; - } - } - - if (request.from) { - const current = getCurrentAddress(); - try { - const fromAddress = Address.parse(request.from); - - if (!fromAddress.equals(current.address)) { - toaster.show({ - ...toasterErrorProps, - message: t('products.transactionRequest.wrongFrom'), - }); - - return false; - } - - } catch (error) { - toaster.show({ - ...toasterErrorProps, - message: t('products.transactionRequest.invalidFrom'), - }); - - return false; - } - } - - return true; -} - export const TonConnectRequestButton = memo((props: TonConnectRequestButtonProps) => { 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/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 19d3d7a36..ac41d88f5 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -315,6 +315,7 @@ const schema: PrepareSchema = { 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' }, diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index 7eb6d09f5..fb03fb14f 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -315,6 +315,7 @@ const schema: PrepareSchema = { "invalidFrom": "Невалидный адрес отправителя", "noConnection": "Приложение не подключено", "expired": "Запрос истек", + "invalidRequest": "Неверный запрос", "failedToReport": "Транзакция отправлена, но не удалось ответить приложению", "failedToReportCanceled": "Транзакция отменена, но не удалось ответить приложению" }, diff --git a/app/i18n/schema.ts b/app/i18n/schema.ts index 4e015a34d..de9e76cef 100644 --- a/app/i18n/schema.ts +++ b/app/i18n/schema.ts @@ -317,6 +317,7 @@ export type LocalizationSchema = { invalidFrom: string, noConnection: string, expired: string, + invalidRequest: string, failedToReport: string, failedToReportCanceled: string, }, diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index caa0c0922..fa9732743 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -424,8 +424,7 @@ export function useLinkNavigator( ? t('products.transactionRequest.failedToReportCanceled') : t('products.transactionRequest.failedToReport'), ...toastProps, - type: 'error', - duration: ToastDuration.LONG + type: 'error' }); } // avoid double sending From bb064981a9752d30c14a5787fa237e57c1f2b0ab Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 2 Jul 2024 10:59:38 +0300 Subject: [PATCH 17/54] add: verif go mining jetton master --- assets/jettons/knownJettons.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From f4ed0068312a289c0218323897c14a58de2bf825 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 3 Jul 2024 16:56:29 +0300 Subject: [PATCH 18/54] fix: holders value formatting --- app/components/ValueComponent.tsx | 3 ++- app/components/products/HoldersAccountItem.tsx | 5 +++-- app/components/products/HoldersPrepaidCard.tsx | 9 +++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) 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/products/HoldersAccountItem.tsx b/app/components/products/HoldersAccountItem.tsx index ec0ab9f60..cdbf6145f 100644 --- a/app/components/products/HoldersAccountItem.tsx +++ b/app/components/products/HoldersAccountItem.tsx @@ -178,9 +178,10 @@ export const HoldersAccountItem = memo((props: { {` ${props.account.cryptoCurrency?.ticker}`} diff --git a/app/components/products/HoldersPrepaidCard.tsx b/app/components/products/HoldersPrepaidCard.tsx index 75cc0b827..18125775a 100644 --- a/app/components/products/HoldersPrepaidCard.tsx +++ b/app/components/products/HoldersPrepaidCard.tsx @@ -125,7 +125,8 @@ export const HoldersPrepaidCard = memo((props: { {title} @@ -142,7 +143,11 @@ export const HoldersPrepaidCard = memo((props: { - + {` ${CurrencySymbols[card.fiatCurrency].symbol}`} From b681b084c20b5ec5c4cd4e0a039ec6f56842572a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 3 Jul 2024 17:03:55 +0300 Subject: [PATCH 19/54] fix: margin --- app/components/products/HoldersPrepaidCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/products/HoldersPrepaidCard.tsx b/app/components/products/HoldersPrepaidCard.tsx index 18125775a..964b4f9ea 100644 --- a/app/components/products/HoldersPrepaidCard.tsx +++ b/app/components/products/HoldersPrepaidCard.tsx @@ -141,7 +141,7 @@ export const HoldersPrepaidCard = memo((props: { - + Date: Thu, 4 Jul 2024 11:49:41 +0300 Subject: [PATCH 20/54] fix: adding timer cleanup --- .../secure/dapps/TonConnectAuthenticateFragment.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/fragments/secure/dapps/TonConnectAuthenticateFragment.tsx b/app/fragments/secure/dapps/TonConnectAuthenticateFragment.tsx index e09c19a0a..9b67862a5 100644 --- a/app/fragments/secure/dapps/TonConnectAuthenticateFragment.tsx +++ b/app/fragments/secure/dapps/TonConnectAuthenticateFragment.tsx @@ -123,10 +123,14 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr })() }, []); + const timerRef = useRef(null); // Approve const active = useRef(true); useEffect(() => { return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } if (!active.current) { return; } @@ -319,7 +323,7 @@ const SignStateLoader = memo(({ connectProps }: { connectProps: TonConnectAuthPr onDestroy: () => { connectProps.callback({ ok: true, replyItems }); - setTimeout(() => { + timerRef.current = setTimeout(() => { navigate.current(); }, 50); }, From c0e047a2b6022bec1f38a92d0a3316445dd324d2 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 4 Jul 2024 19:11:28 +0300 Subject: [PATCH 21/54] fix: hardcode personalisation code --- .../products/HoldersAccountCard.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/components/products/HoldersAccountCard.tsx b/app/components/products/HoldersAccountCard.tsx index 5b5989ec3..fa58f5286 100644 --- a/app/components/products/HoldersAccountCard.tsx +++ b/app/components/products/HoldersAccountCard.tsx @@ -1,5 +1,5 @@ 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"; @@ -21,21 +21,23 @@ 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; - } + // 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 ( From d2d320b7cf42299a97a37705f7d21a4b72e3678a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 8 Jul 2024 13:44:32 +0300 Subject: [PATCH 22/54] wip: adding region filtered listings --- app/components/browser/BrowserTabs.tsx | 40 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/app/components/browser/BrowserTabs.tsx b/app/components/browser/BrowserTabs.tsx index 90c9d6617..132bf4a5f 100644 --- a/app/components/browser/BrowserTabs.tsx +++ b/app/components/browser/BrowserTabs.tsx @@ -6,13 +6,49 @@ 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) => { + let excludedRegions = [] + try { + excludedRegions = JSON.parse(listing.regions_to_exclude || '[]'); + } catch { + // Do nothing + } + + if (!excludedRegions || excludedRegions.length === 0) { + return true; + } + + const storeFrontCode = codes.storeFrontCode; + const countryCode = codes.countryCode; + + if (!storeFrontCode && !countryCode) { + return true; + } + + if (!!storeFrontCode && excludedRegions.includes(storeFrontCode)) { + return false; + } + + if (excludedRegions.includes(countryCode)) { + 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); From e78c4fba1651c43e97a9cad7f1daa22cd21a1145 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 9 Jul 2024 19:02:23 +0300 Subject: [PATCH 23/54] add: adding regions to include --- app/components/browser/BrowserTabs.tsx | 31 +++++++++++++++++--------- app/engine/api/fetchBrowserListings.ts | 1 + 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/app/components/browser/BrowserTabs.tsx b/app/components/browser/BrowserTabs.tsx index 132bf4a5f..0285185de 100644 --- a/app/components/browser/BrowserTabs.tsx +++ b/app/components/browser/BrowserTabs.tsx @@ -13,29 +13,40 @@ import { getCountryCodes } from "../../utils/isNeocryptoAvailable"; function filterByStoreGeoListings(codes: { countryCode: string, storeFrontCode: string | null },) { return (listing: BrowserListingsWithCategory) => { - let excludedRegions = [] + const { countryCode, storeFrontCode } = codes; + + let excludedRegions = [], includedRegions: string[] | null = null; try { excludedRegions = JSON.parse(listing.regions_to_exclude || '[]'); } catch { // Do nothing } - if (!excludedRegions || excludedRegions.length === 0) { - return true; + if (!!listing.regions_to_include) { + try { + includedRegions = JSON.parse(listing.regions_to_include); + } catch { + // Do nothing + } } - const storeFrontCode = codes.storeFrontCode; - const countryCode = codes.countryCode; + // check for excluded regions + const excludedByStore = !!storeFrontCode && excludedRegions.includes(storeFrontCode); + const excludedByCountry = !!countryCode && excludedRegions.includes(countryCode); - if (!storeFrontCode && !countryCode) { - return true; + if (excludedByStore || excludedByCountry) { + return false; } - if (!!storeFrontCode && excludedRegions.includes(storeFrontCode)) { - return false; + if (includedRegions === null) { + return true; } - if (excludedRegions.includes(countryCode)) { + // check for included regions + const includedByStore = !!storeFrontCode ? includedRegions.includes(storeFrontCode) : false; + const includedByCountry = !!countryCode ? includedRegions.includes(countryCode) : false; + + if (!includedByStore && !includedByCountry) { return false; } 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() From 3d9e86a93aa9df1122fcdb70364826b452affe31 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 9 Jul 2024 19:06:11 +0300 Subject: [PATCH 24/54] cleanup: rm useless comment --- app/components/browser/BrowserTabs.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/components/browser/BrowserTabs.tsx b/app/components/browser/BrowserTabs.tsx index 0285185de..4b6357426 100644 --- a/app/components/browser/BrowserTabs.tsx +++ b/app/components/browser/BrowserTabs.tsx @@ -18,16 +18,12 @@ function filterByStoreGeoListings(codes: { countryCode: string, storeFrontCode: let excludedRegions = [], includedRegions: string[] | null = null; try { excludedRegions = JSON.parse(listing.regions_to_exclude || '[]'); - } catch { - // Do nothing - } + } catch { } if (!!listing.regions_to_include) { try { includedRegions = JSON.parse(listing.regions_to_include); - } catch { - // Do nothing - } + } catch { } } // check for excluded regions From 0f32c2a6ad8cad75bd19bdf1da9a16b61c31b219 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 9 Jul 2024 19:16:11 +0300 Subject: [PATCH 25/54] android: target version bump --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From 01a907081a569ae7fc41b4ff9ce2d2450546ba2c Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 10 Jul 2024 18:44:20 +0300 Subject: [PATCH 26/54] fix: isAuthenticated typo --- app/components/webview/DAppWebView.tsx | 16 ++++++++-------- .../apps/components/inject/createInjectSource.ts | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index 05bf21d80..6d0789332 100644 --- a/app/components/webview/DAppWebView.tsx +++ b/app/components/webview/DAppWebView.tsx @@ -168,18 +168,18 @@ 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; + 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') { (async () => { @@ -188,27 +188,27 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar if (isAlreadyLocked) { dispatchLockAppWithAuthResponse( ref as RefObject, - { authenicated: true, lastAuthTime: getLastAuthTimestamp() } + { isAuthenticated: true, lastAuthTime: getLastAuthTimestamp() } ); return; } - let authenicated = false; + let isAuthenticated = false; let lastAuthTime: number | undefined; // wait for auth to complete then set lockApp tag try { await authContext.authenticate(); - authenicated = true; + isAuthenticated = true; lastAuthTime = getLastAuthTimestamp(); } catch { warn('Failed to authenticate'); } - if (authenicated) { + if (isAuthenticated) { setLockAppWithAuth(true); } - dispatchLockAppWithAuthResponse(ref as RefObject, { authenicated, lastAuthTime }); + dispatchLockAppWithAuthResponse(ref as RefObject, { isAuthenticated, lastAuthTime }); })(); } diff --git a/app/fragments/apps/components/inject/createInjectSource.ts b/app/fragments/apps/components/inject/createInjectSource.ts index ea1e9dee4..7900db478 100644 --- a/app/fragments/apps/components/inject/createInjectSource.ts +++ b/app/fragments/apps/components/inject/createInjectSource.ts @@ -163,7 +163,7 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean const authenicate = (callback) => { if (inProgress) { - callback({ authenicated: false, erorr: 'auth.inProgress' }); + callback({ isAuthenticated: false, erorr: 'auth.inProgress' }); return; } @@ -174,7 +174,7 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean const lockAppWithAuth = (callback) => { if (inProgress) { - callback({ authenicated: false, erorr: 'auth.inProgress' }); + callback({ isAuthenticated: false, erorr: 'auth.inProgress' }); return; } @@ -197,13 +197,13 @@ 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 && ev.data.isAuthenticated === true) { params.lastAuthTime = ev.data.lastAuthTime; } if (!!ev.data.isLockedByAuth && ev.data.isLockedByAuth === true) { params.isLockedByAuth = true; } - currentCallback({ authenicated: ev.data.authenicated }); + currentCallback({ isAuthenticated: ev.data.isAuthenticated }); } currentCallback = null; } @@ -325,13 +325,13 @@ 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: { authenicated: boolean, lastAuthTime?: number }) { - let injectedMessage = `window['tonhub-auth'].__response(${JSON.stringify({ data: { ...data, isLockedByAuth: data.authenicated } })}); true;`; +export function dispatchLockAppWithAuthResponse(webRef: React.RefObject, data: { isAuthenticated: boolean, lastAuthTime?: number }) { + let injectedMessage = `window['tonhub-auth'].__response(${JSON.stringify({ data: { ...data, isLockedByAuth: data.isAuthenticated } })}); true;`; webRef.current?.injectJavaScript(injectedMessage); } From 763eb232943e7f2635b476aae72065e2beb544bf Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 11 Jul 2024 00:58:15 +0300 Subject: [PATCH 27/54] fix: optimising hints, adding txs hints sync, prefetching wallet & content on sync --- app/components/products/JettonsList.tsx | 4 ++- app/engine/hooks/jettons/useHints.ts | 36 ++++++++++++++++++- app/engine/hooks/jettons/useSortedHints.ts | 1 - .../hooks/jettons/useSortedHintsWatcher.ts | 16 ++++----- .../transactions/useRawAccountTransactions.ts | 16 +++++++++ app/fragments/AppAuthFragment.tsx | 1 - app/utils/hintSortFilter.ts | 22 ++++++++---- 7 files changed, 78 insertions(+), 18 deletions(-) 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/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..434e49ea1 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"; diff --git a/app/engine/hooks/jettons/useSortedHintsWatcher.ts b/app/engine/hooks/jettons/useSortedHintsWatcher.ts index 4ffe6dba7..99979f865 100644 --- a/app/engine/hooks/jettons/useSortedHintsWatcher.ts +++ b/app/engine/hooks/jettons/useSortedHintsWatcher.ts @@ -27,7 +27,7 @@ function areArraysEqualByContent(a: T[], b: T[]): boolean { } function useSubToHintChange( - onChangeMany: (source?: string) => void, + reSortHints: () => void, owner: string, ) { useEffect(() => { @@ -46,21 +46,20 @@ function useSubToHintChange( 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(); } } }); return unsub; - }, [owner, onChangeMany]); + }, [owner, reSortHints]); } export function useSortedHintsWatcher(address?: string) { @@ -68,13 +67,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/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/fragments/AppAuthFragment.tsx b/app/fragments/AppAuthFragment.tsx index 1b38aa2c9..a5e90a5bc 100644 --- a/app/fragments/AppAuthFragment.tsx +++ b/app/fragments/AppAuthFragment.tsx @@ -90,7 +90,6 @@ export const AppAuthFragment = fragment(() => { const current = AppState.currentState; if (current === 'active') { - console.log('App unblured, current'); setBlur(false); } } 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 }; } From c9ef243039dc2738fb4c8a597c1a2be79ce39c64 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 11 Jul 2024 11:57:42 +0300 Subject: [PATCH 28/54] fix: mixpanel useless events cleanup --- app/analytics/mixpanel.ts | 6 ------ app/components/browser/BrowserBanners.tsx | 5 ----- app/fragments/secure/dapps/AuthenticateFragment.tsx | 6 ------ app/fragments/secure/dapps/InstallFragment.tsx | 8 -------- app/utils/CachedLinking.ts | 4 ---- 5 files changed, 29 deletions(-) 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/browser/BrowserBanners.tsx b/app/components/browser/BrowserBanners.tsx index 43ce591de..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(); @@ -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) { 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/InstallFragment.tsx b/app/fragments/secure/dapps/InstallFragment.tsx index 6070f9651..c17bc3558 100644 --- a/app/fragments/secure/dapps/InstallFragment.tsx +++ b/app/fragments/secure/dapps/InstallFragment.tsx @@ -65,19 +65,11 @@ const SignStateLoader = memo((props: SignStateLoaderParams) => { // 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 ( 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']); } }); From a6423d4b10c437a48113ab69b332b375d635ac0a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 11 Jul 2024 18:20:43 +0300 Subject: [PATCH 29/54] add: adding mandatory app auth setup --- app/components/CheckBox.tsx | 7 +- .../products/HoldersAccountItem.tsx | 6 +- .../products/HoldersPrepaidCard.tsx | 6 +- app/components/products/ProductsComponent.tsx | 6 +- app/components/secure/AuthWalletKeys.tsx | 21 ++- app/components/webview/DAppWebView.tsx | 38 +---- .../hooks/holders/useHasHoldersProducts.ts | 30 +++- app/engine/hooks/settings/index.ts | 3 +- .../hooks/settings/useAppAuthMandatory.ts | 6 + app/engine/state/lockAppWithAuthState.ts | 21 ++- app/fragments/SecurityFragment.tsx | 8 +- .../components/inject/createInjectSource.ts | 20 +-- .../secure/MandatoryAuthSetupFragment.tsx | 149 ++++++++++++++++++ app/fragments/wallet/ProductsFragment.tsx | 6 +- app/i18n/i18n_en.ts | 7 + app/i18n/i18n_ru.ts | 9 +- app/i18n/schema.ts | 7 + app/utils/useTypedNavigation.ts | 40 ++++- assets/ic-warning-banner.svg | 5 + 19 files changed, 320 insertions(+), 75 deletions(-) create mode 100644 app/engine/hooks/settings/useAppAuthMandatory.ts create mode 100644 app/fragments/secure/MandatoryAuthSetupFragment.tsx create mode 100644 assets/ic-warning-banner.svg 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/products/HoldersAccountItem.tsx b/app/components/products/HoldersAccountItem.tsx index cdbf6145f..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(); diff --git a/app/components/products/HoldersPrepaidCard.tsx b/app/components/products/HoldersPrepaidCard.tsx index 964b4f9ea..d88a89026 100644 --- a/app/components/products/HoldersPrepaidCard.tsx +++ b/app/components/products/HoldersPrepaidCard.tsx @@ -61,12 +61,12 @@ 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(); diff --git a/app/components/products/ProductsComponent.tsx b/app/components/products/ProductsComponent.tsx index 83d9e3fba..af64ef000 100644 --- a/app/components/products/ProductsComponent.tsx +++ b/app/components/products/ProductsComponent.tsx @@ -67,11 +67,11 @@ export const ProductsComponent = memo(({ selected }: { selected: SelectedAccount const onHoldersPress = useCallback(() => { 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/secure/AuthWalletKeys.tsx b/app/components/secure/AuthWalletKeys.tsx index 2d2afb24a..9298a36fa 100644 --- a/app/components/secure/AuthWalletKeys.tsx +++ b/app/components/secure/AuthWalletKeys.tsx @@ -48,15 +48,22 @@ export type AuthParams = { selectedAccount?: SelectedAccount } +export enum AuthRejectReason { + Canceled = 'canceled', + InProgress = 'in-progress', + PasscodeNotSet = 'passcode-not-set', + NoPasscode = 'no-passcode', +} + export type AuthProps = | { returns: 'keysWithPasscode', - promise: { resolve: (res: { keys: WalletKeys, passcode: string }) => 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 } @@ -143,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 @@ -273,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 @@ -284,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: { @@ -380,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; } @@ -417,7 +424,7 @@ export const AuthWalletKeysContextProvider = memo((props: { children?: any }) => {auth.params?.cancelable && ( { - auth.promise.reject(); + auth.promise.reject(AuthRejectReason.Canceled); setAuth(null); }} style={{ diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index 6d0789332..d6426b8c2 100644 --- a/app/components/webview/DAppWebView.tsx +++ b/app/components/webview/DAppWebView.tsx @@ -172,7 +172,7 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar let lastAuthTime: number | undefined; // wait for auth to complete try { - await authContext.authenticate(); + await authContext.authenticate({ cancelable: true, paddingTop: 32 }); isAuthenticated = true; lastAuthTime = getLastAuthTimestamp(); } catch { @@ -182,34 +182,11 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar dispatchAuthResponse(ref as RefObject, { isAuthenticated, lastAuthTime }); })(); } else if (method === 'lockAppWithAuth') { - (async () => { - - const isAlreadyLocked = getLockAppWithAuthState(); - if (isAlreadyLocked) { - dispatchLockAppWithAuthResponse( - ref as RefObject, - { isAuthenticated: true, lastAuthTime: getLastAuthTimestamp() } - ); - return; - } - - let isAuthenticated = false; - let lastAuthTime: number | undefined; - // wait for auth to complete then set lockApp tag - try { - await authContext.authenticate(); - isAuthenticated = true; - lastAuthTime = getLastAuthTimestamp(); - } catch { - warn('Failed to authenticate'); - } - - if (isAuthenticated) { - setLockAppWithAuth(true); - } - - dispatchLockAppWithAuthResponse(ref as RefObject, { isAuthenticated, lastAuthTime }); - })(); + const callback = (isSecured: boolean) => { + const lastAuthTime = getLastAuthTimestamp(); + dispatchLockAppWithAuthResponse(ref as RefObject, { isSecured, lastAuthTime }); + } + navigation.navigateMandatoryAuthSetup({ callback }); } return; @@ -398,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() ?? false }) : ''} ${props.injectedJavaScriptBeforeContentLoaded ?? ''} (() => { @@ -500,6 +477,7 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar /> ) }} + webviewDebuggingEnabled={__DEV__} /> 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/settings/index.ts b/app/engine/hooks/settings/index.ts index 2357374d8..3733733f0 100644 --- a/app/engine/hooks/settings/index.ts +++ b/app/engine/hooks/settings/index.ts @@ -1 +1,2 @@ -export { useLockAppWithAuthState } from './useLockAppWithAuthState'; \ No newline at end of file +export { useLockAppWithAuthState } from './useLockAppWithAuthState'; +export { useAppAuthMandatory } from './useAppAuthMandatory'; \ No newline at end of file diff --git a/app/engine/hooks/settings/useAppAuthMandatory.ts b/app/engine/hooks/settings/useAppAuthMandatory.ts new file mode 100644 index 000000000..20d321bea --- /dev/null +++ b/app/engine/hooks/settings/useAppAuthMandatory.ts @@ -0,0 +1,6 @@ +import { useRecoilState } from "recoil"; +import { lockAppWithAuthMandatoryState } from "../../state/lockAppWithAuthState"; + +export function useAppAuthMandatory() { + return useRecoilState(lockAppWithAuthMandatoryState); +} \ No newline at end of file diff --git a/app/engine/state/lockAppWithAuthState.ts b/app/engine/state/lockAppWithAuthState.ts index 4e2fd82a3..f13966415 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; @@ -19,4 +20,22 @@ export const lockAppWithAuthState = atom({ storeLockAppWithAuthState(newValue); }) }] +}); + +export function getLockAppWithAuthMandatory() { + return storage.getBoolean(lockAppWithAuthMandatoryKey) || false; +} + +function storeLockAppWithAuthMandatory(value: boolean) { + storage.set(lockAppWithAuthMandatoryKey, value); +} + +export const lockAppWithAuthMandatoryState = atom({ + key: 'auth/lockAppWithAuthState/mandatory', + default: getLockAppWithAuthMandatory(), + effects: [({ onSet }) => { + onSet((newValue) => { + storeLockAppWithAuthMandatory(newValue); + }) + }] }); \ No newline at end of file diff --git a/app/fragments/SecurityFragment.tsx b/app/fragments/SecurityFragment.tsx index 91fdfd6d0..58cb19711 100644 --- a/app/fragments/SecurityFragment.tsx +++ b/app/fragments/SecurityFragment.tsx @@ -6,7 +6,7 @@ import { fragment } from "../fragment" import { t } from "../i18n/t" import { BiometricsState, PasscodeState } from "../storage/secureStorage" import { useTypedNavigation } from "../utils/useTypedNavigation" -import { useHasHoldersProducts, useSelectedAccount, useTheme } from '../engine/hooks'; +import { useSelectedAccount, useTheme } from '../engine/hooks'; import { useEffect, useMemo, useState } from "react" import { DeviceEncryption, getDeviceEncryption } from "../storage/getDeviceEncryption" import { Ionicons } from '@expo/vector-icons'; @@ -17,7 +17,7 @@ import { usePasscodeState } from '../engine/hooks' import { useBiometricsState } from '../engine/hooks' import { useSetBiometricsState } from "../engine/hooks/appstate/useSetBiometricsState" import { ScreenHeader } from "../components/ScreenHeader" -import { useLockAppWithAuthState } from "../engine/hooks/settings" +import { useAppAuthMandatory, useLockAppWithAuthState } from "../engine/hooks/settings" import { StatusBar } from "expo-status-bar" import TouchAndroid from '@assets/ic_touch_and.svg'; @@ -32,11 +32,11 @@ export const SecurityFragment = fragment(() => { const passcodeState = usePasscodeState(); const biometricsState = useBiometricsState(); const setBiometricsState = useSetBiometricsState(); - const hasHoldersProducts = useHasHoldersProducts(selectedAccount?.address ?? ''); + const [mandatoryAuth,] = useAppAuthMandatory(); const [deviceEncryption, setDeviceEncryption] = useState(); const [lockAppWithAuthState, setLockAppWithAuthState] = useLockAppWithAuthState(); - const canToggleAppAuth = !(hasHoldersProducts && lockAppWithAuthState); + const canToggleAppAuth = !(mandatoryAuth && lockAppWithAuthState); const biometricsProps = useMemo(() => { if (passcodeState !== PasscodeState.Set) { diff --git a/app/fragments/apps/components/inject/createInjectSource.ts b/app/fragments/apps/components/inject/createInjectSource.ts index 7900db478..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; @@ -161,7 +161,7 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean window.ReactNativeWebView.postMessage(JSON.stringify({ data: { name: 'auth.getLastAuthTime' } })); } - const authenicate = (callback) => { + const authenticate = (callback) => { if (inProgress) { callback({ isAuthenticated: false, erorr: 'auth.inProgress' }); return; @@ -197,19 +197,21 @@ export const authAPI = (params: { lastAuthTime?: number, isLockedByAuth: boolean params.lastAuthTime = ev.data; currentCallback(ev.data); } else { - if (!!ev.data.lastAuthTime && ev.data.isAuthenticated === true) { + if (!!ev.data.lastAuthTime) { params.lastAuthTime = ev.data.lastAuthTime; } - if (!!ev.data.isLockedByAuth && ev.data.isLockedByAuth === true) { - params.isLockedByAuth = true; + 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({ isAuthenticated: ev.data.isAuthenticated }); } currentCallback = null; } } - const obj = { __AUTH_AVAILIBLE, params, authenicate, getLastAuthTime, lockAppWithAuth, __response }; + const obj = { __AUTH_AVAILIBLE, params, authenticate, getLastAuthTime, lockAppWithAuth, __response }; Object.freeze(obj); return obj; })(); @@ -330,8 +332,8 @@ export function dispatchAuthResponse(webRef: React.RefObject, data: { i webRef.current?.injectJavaScript(injectedMessage); } -export function dispatchLockAppWithAuthResponse(webRef: React.RefObject, data: { isAuthenticated: boolean, lastAuthTime?: number }) { - let injectedMessage = `window['tonhub-auth'].__response(${JSON.stringify({ data: { ...data, isLockedByAuth: data.isAuthenticated } })}); true;`; +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/secure/MandatoryAuthSetupFragment.tsx b/app/fragments/secure/MandatoryAuthSetupFragment.tsx new file mode 100644 index 000000000..b131c5638 --- /dev/null +++ b/app/fragments/secure/MandatoryAuthSetupFragment.tsx @@ -0,0 +1,149 @@ +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, useEffect, useState } from "react"; +import { ScrollView } from "react-native-gesture-handler"; +import { RoundButton } from "../../components/RoundButton"; +import { useAppAuthMandatory, 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 [mandatoryAuth, setMandatoryAuth] = useAppAuthMandatory(); + 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); + } + + setMandatoryAuth(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' }); + } + } + }, [mandatoryAuth, 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/wallet/ProductsFragment.tsx b/app/fragments/wallet/ProductsFragment.tsx index 9ffda49c2..63728e1be 100644 --- a/app/fragments/wallet/ProductsFragment.tsx +++ b/app/fragments/wallet/ProductsFragment.tsx @@ -42,12 +42,12 @@ export const ProductsFragment = fragment(() => { 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/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index ac41d88f5..21d9403a1 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -1070,6 +1070,13 @@ 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', + } }; export default schema; diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index fb03fb14f..e1099f1df 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -815,7 +815,7 @@ const schema: PrepareSchema = { "restore": "Восстановить" }, "canceled": { - "title": "Отменено", + "title": "Отменa", "message": "Аутентификация была отменена, пожалуйста, повторите попытку" } } @@ -1070,6 +1070,13 @@ const schema: PrepareSchema = { "termsAndPrivacy": 'Я прочитал и согласен с ', "dontShowTitle": 'Больше не показывать для DeDust.io', }, + mandatoryAuth: { + title: 'Проверьте Seed фразу', + description: 'Вкличите верификацию при открытии кошелька. Храните данные ваших карт в безопасности.', + alert: 'Сохраните 24 секретных слова (Seed фразу) от вашего кошелька. Это поможет вам восстановить доступ, если вы потеряете телефон или забудете пин-код.', + confirmDescription: '24 секретных слова записаны и хранятся в надежном месте', + action: 'Включить', + } }; export default schema; \ No newline at end of file diff --git a/app/i18n/schema.ts b/app/i18n/schema.ts index de9e76cef..fb38984a5 100644 --- a/app/i18n/schema.ts +++ b/app/i18n/schema.ts @@ -1072,6 +1072,13 @@ export type LocalizationSchema = { termsAndPrivacy: string, dontShowTitle: string, }, + mandatoryAuth: { + title: string, + description: string, + alert: string, + confirmDescription: string, + action: string, + } }; export type LocalizedResources = Paths; diff --git a/app/utils/useTypedNavigation.ts b/app/utils/useTypedNavigation.ts index 7af734a1c..88d0f583b 100644 --- a/app/utils/useTypedNavigation.ts +++ b/app/utils/useTypedNavigation.ts @@ -14,6 +14,10 @@ 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 { getLockAppWithAuthMandatory, getLockAppWithAuthState } from '../engine/state/lockAppWithAuthState'; +import { getHasHoldersProducts } from '../engine/hooks/holders/useHasHoldersProducts'; +import { getCurrentAddress } from '../storage/appState'; type Base = NavigationProp; @@ -39,6 +43,15 @@ export function typedNavigateAndReplaceAll(src: Base, name: string, params?: any src.reset({ index: 0, routes: [{ name, params }] }); } +function shouldTurnAuthOn(isTestnet: boolean) { + const isAppAuthOn = getLockAppWithAuthState(); + const isMandatoryAuthOn = getLockAppWithAuthMandatory(); + const currentAccount = getCurrentAddress(); + const hasAccounts = getHasHoldersProducts(currentAccount.address.toString({ testOnly: isTestnet })); + + return (!isAppAuthOn || !isMandatoryAuthOn) && hasAccounts; +} + export class TypedNavigation { readonly base: any; constructor(navigation: any) { @@ -154,11 +167,28 @@ 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) { + 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 }); + } this.navigate('Holders', params); } @@ -204,6 +234,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/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 @@ + + + + + From 49899c7c16d3c5bc804119089a9bf3fe7d441616 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 11 Jul 2024 18:22:31 +0300 Subject: [PATCH 30/54] add: adding navigation route --- app/Navigation.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Navigation.tsx b/app/Navigation.tsx index e5ceba644..1cbff4afb 100644 --- a/app/Navigation.tsx +++ b/app/Navigation.tsx @@ -96,6 +96,7 @@ 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'; @@ -319,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), From 26d4b24b939b72ae4526a2e5f060b4dc8b7ae233 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 11 Jul 2024 18:55:58 +0300 Subject: [PATCH 31/54] fix: sorted hints atom selfSet, optimising updates observer --- app/engine/hooks/jettons/useSortedHints.ts | 6 ++---- .../hooks/jettons/useSortedHintsWatcher.ts | 21 ++++++++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/engine/hooks/jettons/useSortedHints.ts b/app/engine/hooks/jettons/useSortedHints.ts index 434e49ea1..051baec4d 100644 --- a/app/engine/hooks/jettons/useSortedHints.ts +++ b/app/engine/hooks/jettons/useSortedHints.ts @@ -30,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 99979f865..0fd99bd27 100644 --- a/app/engine/hooks/jettons/useSortedHintsWatcher.ts +++ b/app/engine/hooks/jettons/useSortedHintsWatcher.ts @@ -33,13 +33,19 @@ function useSubToHintChange( 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 ?? [])) { @@ -50,9 +56,18 @@ function useSubToHintChange( } else if ( (queryKey[0] === 'contractMetadata') || (queryKey[0] === 'account' && queryKey[2] === 'jettonWallet') - || (queryKey[0] === 'jettons' && queryKey[1] === 'swap') || (queryKey[0] === 'jettons' && queryKey[1] === 'master' && queryKey[3] === 'content') ) { + 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(); } } From e154316fd00d06b508b7b08b51cf64118714e2c0 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 12 Jul 2024 11:18:00 +0300 Subject: [PATCH 32/54] fix: rm dev prop --- app/components/webview/DAppWebView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index d6426b8c2..abac4ff85 100644 --- a/app/components/webview/DAppWebView.tsx +++ b/app/components/webview/DAppWebView.tsx @@ -477,7 +477,6 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar /> ) }} - webviewDebuggingEnabled={__DEV__} /> Date: Mon, 15 Jul 2024 17:23:14 +0300 Subject: [PATCH 33/54] wip: fixing holders accs with no cards height and rm 'no cards display' & jettons items layout --- .../animated/AnimatedChildrenCollapsible.tsx | 57 ++++++------ app/components/animated/CollapsibleCards.tsx | 86 +++++++++++-------- app/components/products/HoldersAccounts.tsx | 6 +- .../HoldersHiddenProductComponent.tsx | 10 ++- .../products/JettonsHiddenComponent.tsx | 5 +- .../LedgerJettonsProductComponent.tsx | 7 +- app/fragments/wallet/ProductsListFragment.tsx | 1 + 7 files changed, 99 insertions(+), 73 deletions(-) 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/products/HoldersAccounts.tsx b/app/components/products/HoldersAccounts.tsx index 833e8604e..f7bd5d386 100644 --- a/app/components/products/HoldersAccounts.tsx +++ b/app/components/products/HoldersAccounts.tsx @@ -74,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..c7d71d424 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, backgroundColor: 'red' }} isTestnet={network.isTestnet} holdersAccStatus={holdersAccStatus} + hideCardsIfEmpty /> ) }} 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/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 : ( ); }, From a85aa0feea46757fd0ec909a78e68008079b5c15 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 15 Jul 2024 17:24:23 +0300 Subject: [PATCH 34/54] fix: typo --- app/i18n/i18n_ru.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index e1099f1df..ff186ae22 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -1072,7 +1072,7 @@ const schema: PrepareSchema = { }, mandatoryAuth: { title: 'Проверьте Seed фразу', - description: 'Вкличите верификацию при открытии кошелька. Храните данные ваших карт в безопасности.', + description: 'Включите верификацию при открытии кошелька. Храните данные ваших карт в безопасности.', alert: 'Сохраните 24 секретных слова (Seed фразу) от вашего кошелька. Это поможет вам восстановить доступ, если вы потеряете телефон или забудете пин-код.', confirmDescription: '24 секретных слова записаны и хранятся в надежном месте', action: 'Включить', From 7b5e6e10d8d413aadd0024bcee9866f658759b70 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 15 Jul 2024 17:48:04 +0300 Subject: [PATCH 35/54] fix: rm dev color --- app/components/products/HoldersHiddenProductComponent.tsx | 2 +- app/fragments/wallet/ProductsListFragment.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/products/HoldersHiddenProductComponent.tsx b/app/components/products/HoldersHiddenProductComponent.tsx index c7d71d424..3d82124f2 100644 --- a/app/components/products/HoldersHiddenProductComponent.tsx +++ b/app/components/products/HoldersHiddenProductComponent.tsx @@ -93,7 +93,7 @@ export const HoldersHiddenProductComponent = memo(({ holdersAccStatus }: { holde rightAction={() => markAccount(item.id, false)} rightActionIcon={} single={hiddenAccountsList.length === 1} - style={{ flex: undefined, backgroundColor: 'red' }} + style={{ flex: undefined }} isTestnet={network.isTestnet} holdersAccStatus={holdersAccStatus} hideCardsIfEmpty diff --git a/app/fragments/wallet/ProductsListFragment.tsx b/app/fragments/wallet/ProductsListFragment.tsx index 9ef9e1f56..d5729bb4a 100644 --- a/app/fragments/wallet/ProductsListFragment.tsx +++ b/app/fragments/wallet/ProductsListFragment.tsx @@ -114,7 +114,7 @@ export const ProductsListFragment = fragment(() => { return ( {type === 'jettons' ? ( - }> + }> ) : ( From 94aa06fd0e11507afdea7c33d91459e9db37a96a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 15 Jul 2024 18:52:55 +0300 Subject: [PATCH 36/54] fix: AuthSetup navigation --- app/utils/useTypedNavigation.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/utils/useTypedNavigation.ts b/app/utils/useTypedNavigation.ts index 88d0f583b..a11eba3a4 100644 --- a/app/utils/useTypedNavigation.ts +++ b/app/utils/useTypedNavigation.ts @@ -18,6 +18,7 @@ import { MandatoryAuthSetupParams } from '../fragments/secure/MandatoryAuthSetup import { getLockAppWithAuthMandatory, 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; @@ -171,7 +172,12 @@ export class TypedNavigation { if (shouldTurnAuthOn(isTestnet)) { const callback = (success: boolean) => { if (success) { // navigate only if auth is set up - this.navigate('HoldersLanding', { endpoint, onEnrollType }) + if (Platform.OS === 'android') { + this.replace('HoldersLanding', { endpoint, onEnrollType }); + } else { + this.goBack(); // close modal + this.navigate('HoldersLanding', { endpoint, onEnrollType }) + } } } this.navigateMandatoryAuthSetup({ callback }); @@ -184,7 +190,12 @@ export class TypedNavigation { if (shouldTurnAuthOn(isTestnet)) { const callback = (success: boolean) => { if (success) { // navigate only if auth is set up - this.navigate('Holders', params); + if (Platform.OS === 'android') { + this.replace('Holders', params); + } else { + this.goBack(); // close modal + this.navigate('Holders', params); + } } } this.navigateMandatoryAuthSetup({ callback }); From 01f21322c6f71b9e4a9dcdf0961c963e96c5c6b9 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 11:18:45 +0300 Subject: [PATCH 37/54] fix: Disable mandatory auth if no holders products left on device --- app/fragments/LogoutFragment.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/app/fragments/LogoutFragment.tsx b/app/fragments/LogoutFragment.tsx index 28eff747f..135be3adb 100644 --- a/app/fragments/LogoutFragment.tsx +++ b/app/fragments/LogoutFragment.tsx @@ -9,17 +9,30 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import { ScreenHeader } from "../components/ScreenHeader"; import { ItemButton } from "../components/ItemButton"; import { openWithInApp } from "../utils/openWithInApp"; -import { useTheme } from "../engine/hooks"; +import { useNetwork, useTheme } from "../engine/hooks"; import { useDeleteCurrentAccount } from "../engine/hooks/appstate/useDeleteCurrentAccount"; import { StatusBar } from "expo-status-bar"; +import { useAppAuthMandatory } from "../engine/hooks/settings"; +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.every((acc) => { + return getHasHoldersProducts(acc.address.toString({ testOnly: isTestnet })); + }); +} + export const LogoutFragment = fragment(() => { const theme = useTheme(); + const { isTestnet } = useNetwork(); const safeArea = useSafeAreaInsets(); const navigation = useTypedNavigation(); + const [, setMandatoryAuth] = useAppAuthMandatory(); const { showActionSheetWithOptions } = useActionSheet(); const onAccountDeleted = useDeleteCurrentAccount(); @@ -50,7 +63,16 @@ export const LogoutFragment = fragment(() => { const onLogout = useCallback(async () => { onAccountDeleted(); - }, [onAccountDeleted]); + + // Check if there are any holders products left on other accounts + const hasHoldersProductsLeft = hasHoldersProductsOnDevice(isTestnet); + + // if not, disable mandatory auth + if (!hasHoldersProductsLeft) { + setMandatoryAuth(false); + } + + }, [isTestnet, onAccountDeleted]); const showLogoutActSheet = useCallback(() => { if (isShown) { From f03ba2e3a8c1715ac8cc2dd938887c1442c6836a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 11:22:29 +0300 Subject: [PATCH 38/54] fix: hasHoldersProductsOnDevice search --- app/fragments/LogoutFragment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/fragments/LogoutFragment.tsx b/app/fragments/LogoutFragment.tsx index 135be3adb..c0d2f35b0 100644 --- a/app/fragments/LogoutFragment.tsx +++ b/app/fragments/LogoutFragment.tsx @@ -22,7 +22,7 @@ import Support from '@assets/ic-support.svg'; function hasHoldersProductsOnDevice(isTestnet: boolean) { const appState = getAppState(); - return appState.addresses.every((acc) => { + return !!appState.addresses.find((acc) => { return getHasHoldersProducts(acc.address.toString({ testOnly: isTestnet })); }); } From 3c664162796065837558c7e775b37c875b11d07a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 14:30:06 +0300 Subject: [PATCH 39/54] fix: fixing fragments layouts for smaller screen sizes --- app/components/Contacts/ContactEdit.tsx | 1 + app/components/Contacts/ContactField.tsx | 1 + app/fragments/contacts/ContactNewFragment.tsx | 52 +++---- app/fragments/contacts/ContactsFragment.tsx | 134 ++++++++---------- .../onboarding/WalletCreateFragment.tsx | 15 +- app/fragments/wallet/AvatarPickerFragment.tsx | 16 +-- 6 files changed, 95 insertions(+), 124 deletions(-) diff --git a/app/components/Contacts/ContactEdit.tsx b/app/components/Contacts/ContactEdit.tsx index 5e449f7e9..653e0dbd7 100644 --- a/app/components/Contacts/ContactEdit.tsx +++ b/app/components/Contacts/ContactEdit.tsx @@ -233,6 +233,7 @@ export const ContactEdit = memo(({ blurOnSubmit={true} editable={true} onFocus={() => onFocus(0)} + cursorColor={theme.accent} /> ) diff --git a/app/fragments/contacts/ContactNewFragment.tsx b/app/fragments/contacts/ContactNewFragment.tsx index 49d43415d..70b9a598f 100644 --- a/app/fragments/contacts/ContactNewFragment.tsx +++ b/app/fragments/contacts/ContactNewFragment.tsx @@ -1,6 +1,6 @@ import { useKeyboard } from "@react-native-community/hooks"; import React, { RefObject, createRef, useCallback, useEffect, useMemo, useState } from "react"; -import { Platform, View, Text, Alert, Keyboard, TextInput, KeyboardAvoidingView } from "react-native"; +import { Platform, View, Text, Alert, Keyboard, TextInput, KeyboardAvoidingView, ScrollView } from "react-native"; import Animated, { runOnUI, useAnimatedRef, useSharedValue, measure, scrollTo, FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ContactField } from "../../components/Contacts/ContactField"; @@ -161,7 +161,7 @@ export const ContactNewFragment = fragment(() => { 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..a095c44e9 100644 --- a/app/fragments/onboarding/WalletCreateFragment.tsx +++ b/app/fragments/onboarding/WalletCreateFragment.tsx @@ -58,9 +58,10 @@ export const WalletCreateFragment = systemFragment(() => { return ( console.log(e.nativeEvent.layout)} style={[ - { flexGrow: 1 }, Platform.select({ android: { paddingBottom: safeArea.bottom + 16 } }), + { flexGrow: 1, alignSelf: 'stretch', alignItems: 'center' } ]} > @@ -93,12 +94,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/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 From 6a4afb5f5ef48a619fbfe909671ee9057e980b9b Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 14:30:16 +0300 Subject: [PATCH 40/54] fix: fixing texts --- app/i18n/i18n_en.ts | 2 +- app/i18n/i18n_ru.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 21d9403a1..207342235 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -630,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', }, diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index ff186ae22..e94ce186c 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -630,7 +630,7 @@ const schema: PrepareSchema = { "addNew": "Создать новый кошелек", "inProgress": "Создаем...", "backupTitle": "Ваша seed-фраза", - "backupSubtitle": "Запишите эти слова в том же порядке и сохраните их в надежном месте", + "backupSubtitle": "Запишите эти 24 слова в том же порядке и сохраните их в надежном месте", "okSaved": "ОК, всё записано", "copy": "Скопировать в буфер обмена" }, From fd4f29bd7b151349933645a8c3cc21ee4e9583cd Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 14:36:39 +0300 Subject: [PATCH 41/54] cleanup: rm dev log --- app/fragments/onboarding/WalletCreateFragment.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/fragments/onboarding/WalletCreateFragment.tsx b/app/fragments/onboarding/WalletCreateFragment.tsx index a095c44e9..abdf941dd 100644 --- a/app/fragments/onboarding/WalletCreateFragment.tsx +++ b/app/fragments/onboarding/WalletCreateFragment.tsx @@ -58,7 +58,6 @@ export const WalletCreateFragment = systemFragment(() => { return ( console.log(e.nativeEvent.layout)} style={[ Platform.select({ android: { paddingBottom: safeArea.bottom + 16 } }), { flexGrow: 1, alignSelf: 'stretch', alignItems: 'center' } From d391eb2dec6a0ab145bd7062d36650c31a797230 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 16:04:50 +0300 Subject: [PATCH 42/54] fix: texts & add switch description --- app/fragments/SecurityFragment.tsx | 16 ++++++++++++++-- app/i18n/i18n_en.ts | 3 ++- app/i18n/i18n_ru.ts | 21 +++++++++++---------- app/i18n/schema.ts | 1 + 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/app/fragments/SecurityFragment.tsx b/app/fragments/SecurityFragment.tsx index 58cb19711..8e19ecd15 100644 --- a/app/fragments/SecurityFragment.tsx +++ b/app/fragments/SecurityFragment.tsx @@ -1,5 +1,5 @@ import React from "react" -import { Platform, View, ScrollView, Image, AppState } from "react-native" +import { Platform, View, ScrollView, Image, AppState, Text } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { ItemButton } from "../components/ItemButton" import { fragment } from "../fragment" @@ -22,9 +22,9 @@ import { StatusBar } from "expo-status-bar" import TouchAndroid from '@assets/ic_touch_and.svg'; import FaceIos from '@assets/ic_face_id.svg'; +import { Typography } from "../components/styles" export const SecurityFragment = fragment(() => { - const selectedAccount = useSelectedAccount(); const safeArea = useSafeAreaInsets(); const navigation = useTypedNavigation(); const authContext = useKeysAuth(); @@ -208,6 +208,13 @@ export const SecurityFragment = fragment(() => { )} )} + + { disabled={!canToggleAppAuth} /> + {!canToggleAppAuth && ( + + {t('mandatoryAuth.settingsDescription')} + + )} ) diff --git a/app/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 21d9403a1..82f712917 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -659,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: 'Authenticaticate when logging into the app', methodPasscode: 'passcode', passcodeSetupDescription: 'PIN code helps to protect your wallet from unauthorized access' }, @@ -1076,6 +1076,7 @@ const schema: PrepareSchema = { 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 mandatory as the app displays banking products' } }; diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index ff186ae22..b68804dba 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -647,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": "Пожалуйста, разрешите приложению доступ к ключам для обновления. Обязательно убедитесь в том, что ваши секретные слова надежно сохранены. Все средства находящиеся на вашем балансе сохранятся.", @@ -659,7 +659,7 @@ const schema: PrepareSchema = { "onLaterMessage": "Вы можете включить защиту позже в настройках приложения", "onLaterButton": "Включить позже", "onBiometricsError": "Ошибка подтверждения биометрии", - "lockAppWithAuth": "Запрашивать пин-код при входе", + "lockAppWithAuth": "Авторизация при входе в приложение", "methodPasscode": "паролем", "passcodeSetupDescription": "Пин-код помогает защитить ваш кошелек от несанкционированного доступа" }, @@ -1070,12 +1070,13 @@ const schema: PrepareSchema = { "termsAndPrivacy": 'Я прочитал и согласен с ', "dontShowTitle": 'Больше не показывать для DeDust.io', }, - mandatoryAuth: { - title: 'Проверьте Seed фразу', - description: 'Включите верификацию при открытии кошелька. Храните данные ваших карт в безопасности.', - alert: 'Сохраните 24 секретных слова (Seed фразу) от вашего кошелька. Это поможет вам восстановить доступ, если вы потеряете телефон или забудете пин-код.', - confirmDescription: '24 секретных слова записаны и хранятся в надежном месте', - action: 'Включить', + "mandatoryAuth": { + "title": 'Проверьте Seed фразу', + "description": 'Включите верификацию при открытии кошелька. Храните данные ваших карт в безопасности.', + "alert": 'Сохраните 24 секретных слова (Seed фразу) от вашего кошелька. Это поможет вам восстановить доступ, если вы потеряете телефон или забудете пин-код.', + "confirmDescription": '24 секретных слова записаны и хранятся в надежном месте', + "action": 'Включить', + "settingsDescription": 'Авторизация обязательна, так как в приложении отображаются банковские продукты' } }; diff --git a/app/i18n/schema.ts b/app/i18n/schema.ts index fb38984a5..244ab6bc3 100644 --- a/app/i18n/schema.ts +++ b/app/i18n/schema.ts @@ -1078,6 +1078,7 @@ export type LocalizationSchema = { alert: string, confirmDescription: string, action: string, + settingsDescription: string } }; From b44911bc5a5487fddd73564b812e9af415a64a0a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 16:08:06 +0300 Subject: [PATCH 43/54] fix: navigation --- app/utils/useTypedNavigation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/utils/useTypedNavigation.ts b/app/utils/useTypedNavigation.ts index a11eba3a4..8ec8a3d51 100644 --- a/app/utils/useTypedNavigation.ts +++ b/app/utils/useTypedNavigation.ts @@ -199,8 +199,9 @@ export class TypedNavigation { } } this.navigateMandatoryAuthSetup({ callback }); + } else { + this.navigate('Holders', params); } - this.navigate('Holders', params); } navigateConnectAuth(params: TonConnectAuthProps) { From 04b846e8e256d0778a76f391c4f3bbe42ceaa57a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 16:08:21 +0300 Subject: [PATCH 44/54] fix: item switch style --- app/components/Item.tsx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) 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: { )} From c9fbfd513cc7404b140e665ad67ab7c38a9ccc12 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 16:10:53 +0300 Subject: [PATCH 45/54] fix: typo --- app/i18n/i18n_en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 82f712917..394707ee3 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -659,7 +659,7 @@ const schema: PrepareSchema = { onLaterMessage: 'You can setup protection later in settings', onLaterButton: 'Setup later', onBiometricsError: 'Error authenticating with biometrics', - lockAppWithAuth: 'Authenticaticate when logging into the app', + lockAppWithAuth: 'Authenticate when logging into the app', methodPasscode: 'passcode', passcodeSetupDescription: 'PIN code helps to protect your wallet from unauthorized access' }, From 8067857f5a6638b7b50ce01cda719a1b80b96208 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 21:14:19 +0300 Subject: [PATCH 46/54] wip: adding push support for passing down in-app/external urls to open to --- app/useLinkNavigator.ts | 13 +- app/utils/resolveUrl.ts | 263 ++++++++++++++++++++++------------------ 2 files changed, 154 insertions(+), 122 deletions(-) diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index fa9732743..d7906c339 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -29,6 +29,8 @@ 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 }); @@ -51,6 +53,7 @@ export function useLinkNavigator( pendingReqsUpdaterRef.current = updatePendingReuests; }, [updatePendingReuests]); + // TODO: split this function into smaller functions const handler = useCallback(async (resolved: ResolvedUrl) => { if (resolved.type === 'transaction') { if (resolved.payload) { @@ -423,7 +426,7 @@ export function useLinkNavigator( message: !ok ? t('products.transactionRequest.failedToReportCanceled') : t('products.transactionRequest.failedToReport'), - ...toastProps, + ...toastProps, type: 'error' }); } @@ -455,6 +458,14 @@ export function useLinkNavigator( } } + if (resolved.type === 'external-url') { + Linking.openURL(resolved.url); + } + + if (resolved.type === 'in-app-url') { + openWithInApp(resolved.url); + } + }, [selected, updateAppState]); return handler; diff --git a/app/utils/resolveUrl.ts b/app/utils/resolveUrl.ts index 766b1947e..87b0082f6 100644 --- a/app/utils/resolveUrl.ts +++ b/app/utils/resolveUrl.ts @@ -14,6 +14,8 @@ export enum ResolveUrlError { InvalidJettonFee = 'InvalidJettonFee', InvalidJettonForward = 'InvalidJettonForward', InvalidJettonAmounts = 'InvalidJettonAmounts', + InvalidInappUrl = 'InvalidInappUrl', + InvalidExternalUrl = 'InvalidExternalUrl', } export type ResolvedUrl = { @@ -52,6 +54,12 @@ export type ResolvedUrl = { address: string, hash: string, lt: string +} | { + type: 'in-app-url', + url: string, +} | { + type: 'external-url', + url: string, } | { type: 'error', error: ResolveUrlError @@ -198,151 +206,165 @@ 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('/')) { + + return resolveTransferUrl(url); - // 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]!; + } 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 ( @@ -374,7 +396,6 @@ export function resolveUrl(src: string, testOnly: boolean): ResolvedUrl | null { warn(e); } - return null; } From c0004a822a69cc0871a533835aa8b6b1c490c099 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 21:42:24 +0300 Subject: [PATCH 47/54] fix: pathname --- app/utils/resolveUrl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/utils/resolveUrl.ts b/app/utils/resolveUrl.ts index 87b0082f6..0710f8869 100644 --- a/app/utils/resolveUrl.ts +++ b/app/utils/resolveUrl.ts @@ -348,14 +348,14 @@ export function resolveUrl(src: string, testOnly: boolean): ResolvedUrl | null { customTitle, customImage }; - } else if (isTonhubHost && url.pathname.toLowerCase() === 'inapp') { // open url with in-app browser + } 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 + } else if (isTonhubHost && url.pathname.toLowerCase() === '/external') { // open url with external browser if (url.query && url.query.url) { return { type: 'external-url', From f61cbcf553ff253bc69d190b9640b8059c310bc7 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 21:53:41 +0300 Subject: [PATCH 48/54] fix: add early returns --- app/useLinkNavigator.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index d7906c339..58fb69785 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -460,10 +460,12 @@ export function useLinkNavigator( if (resolved.type === 'external-url') { Linking.openURL(resolved.url); + return; } - + if (resolved.type === 'in-app-url') { openWithInApp(resolved.url); + return; } }, [selected, updateAppState]); From 5dc566a4a960109157fe52a9915132acd5c617ba Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 16 Jul 2024 23:32:21 +0300 Subject: [PATCH 49/54] wip: adding try catch to avoid leaking keys from SessionCrypto --- app/engine/api/sendTonConnectResponse.ts | 2 +- app/engine/hooks/dapps/useHandleMessage.ts | 2 +- app/useLinkNavigator.ts | 423 ++++++++++++--------- 3 files changed, 235 insertions(+), 192 deletions(-) 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/useHandleMessage.ts b/app/engine/hooks/dapps/useHandleMessage.ts index 7f4692475..3d9cd4179 100644 --- a/app/engine/hooks/dapps/useHandleMessage.ts +++ b/app/engine/hooks/dapps/useHandleMessage.ts @@ -131,7 +131,7 @@ export function useHandleMessage( }); } - } catch (e) { + } catch { warn('Failed to handle message'); } } diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index 58fb69785..db807b7a6 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -1,5 +1,5 @@ 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 { useConnectPendingRequests, useSetAppState } from './engine/hooks'; @@ -9,9 +9,9 @@ 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, useEffect, useRef } 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'; @@ -21,7 +21,7 @@ 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, SendTransactionError, SignRawParams, TonConnectBridgeType } from './engine/tonconnect/types'; +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'; @@ -34,6 +34,225 @@ 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 }, @@ -271,191 +490,15 @@ export function useLinkNavigator( } if (resolved.type === 'tonconnect-request') { - const query = resolved.query; - 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)) { - throw Error('Invalid request'); - } - - 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); - } - } + tryResolveTonconnectRequest({ + query: resolved.query, + isTestnet, + toaster, + toastProps, + navigation, + pendingReqsUpdaterRef, + updateAppState + }); } if (resolved.type === 'external-url') { From d3bb427619e6aaf8e135151f1601ad7d217e795a Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 18 Jul 2024 14:33:34 +0300 Subject: [PATCH 50/54] fix: rm useless state & show * symbols on sens data if no auth is turned on --- .../products/HoldersAccountCard.tsx | 4 +++- .../products/HoldersPrepaidCard.tsx | 20 ++++++++++++----- app/engine/hooks/settings/index.ts | 3 +-- .../hooks/settings/useAppAuthMandatory.ts | 6 ----- app/engine/state/lockAppWithAuthState.ts | 18 --------------- app/fragments/LogoutFragment.tsx | 16 ++------------ app/fragments/SecurityFragment.tsx | 22 +++++++++++++------ .../secure/MandatoryAuthSetupFragment.tsx | 8 +++---- app/i18n/i18n_en.ts | 2 +- app/i18n/i18n_ru.ts | 2 +- app/utils/useTypedNavigation.ts | 5 ++--- 11 files changed, 42 insertions(+), 64 deletions(-) delete mode 100644 app/engine/hooks/settings/useAppAuthMandatory.ts diff --git a/app/components/products/HoldersAccountCard.tsx b/app/components/products/HoldersAccountCard.tsx index fa58f5286..87ca94ebf 100644 --- a/app/components/products/HoldersAccountCard.tsx +++ b/app/components/products/HoldersAccountCard.tsx @@ -4,6 +4,7 @@ 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,6 +22,7 @@ const cardImages = { } export const HoldersAccountCard = memo(({ card, theme }: { card: GeneralHoldersCard, theme: ThemeType }) => { + 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'; @@ -44,7 +46,7 @@ export const HoldersAccountCard = memo(({ card, theme }: { card: GeneralHoldersC {!!card.lastFourDigits && ( - {card.lastFourDigits} + {lockAppWithAuth ? card.lastFourDigits : '****'} )} diff --git a/app/components/products/HoldersPrepaidCard.tsx b/app/components/products/HoldersPrepaidCard.tsx index d88a89026..204846050 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(); @@ -70,7 +72,7 @@ export const HoldersPrepaidCard = memo((props: { 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) @@ -143,11 +145,17 @@ export const HoldersPrepaidCard = memo((props: { - + {lockAppWithAuth ? ( + + ) : ( + + {'****'} + + )} {` ${CurrencySymbols[card.fiatCurrency].symbol}`} diff --git a/app/engine/hooks/settings/index.ts b/app/engine/hooks/settings/index.ts index 3733733f0..2357374d8 100644 --- a/app/engine/hooks/settings/index.ts +++ b/app/engine/hooks/settings/index.ts @@ -1,2 +1 @@ -export { useLockAppWithAuthState } from './useLockAppWithAuthState'; -export { useAppAuthMandatory } from './useAppAuthMandatory'; \ No newline at end of file +export { useLockAppWithAuthState } from './useLockAppWithAuthState'; \ No newline at end of file diff --git a/app/engine/hooks/settings/useAppAuthMandatory.ts b/app/engine/hooks/settings/useAppAuthMandatory.ts deleted file mode 100644 index 20d321bea..000000000 --- a/app/engine/hooks/settings/useAppAuthMandatory.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useRecoilState } from "recoil"; -import { lockAppWithAuthMandatoryState } from "../../state/lockAppWithAuthState"; - -export function useAppAuthMandatory() { - return useRecoilState(lockAppWithAuthMandatoryState); -} \ No newline at end of file diff --git a/app/engine/state/lockAppWithAuthState.ts b/app/engine/state/lockAppWithAuthState.ts index f13966415..740001e00 100644 --- a/app/engine/state/lockAppWithAuthState.ts +++ b/app/engine/state/lockAppWithAuthState.ts @@ -20,22 +20,4 @@ export const lockAppWithAuthState = atom({ storeLockAppWithAuthState(newValue); }) }] -}); - -export function getLockAppWithAuthMandatory() { - return storage.getBoolean(lockAppWithAuthMandatoryKey) || false; -} - -function storeLockAppWithAuthMandatory(value: boolean) { - storage.set(lockAppWithAuthMandatoryKey, value); -} - -export const lockAppWithAuthMandatoryState = atom({ - key: 'auth/lockAppWithAuthState/mandatory', - default: getLockAppWithAuthMandatory(), - effects: [({ onSet }) => { - onSet((newValue) => { - storeLockAppWithAuthMandatory(newValue); - }) - }] }); \ No newline at end of file diff --git a/app/fragments/LogoutFragment.tsx b/app/fragments/LogoutFragment.tsx index c0d2f35b0..cc04cb59a 100644 --- a/app/fragments/LogoutFragment.tsx +++ b/app/fragments/LogoutFragment.tsx @@ -9,10 +9,9 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import { ScreenHeader } from "../components/ScreenHeader"; import { ItemButton } from "../components/ItemButton"; import { openWithInApp } from "../utils/openWithInApp"; -import { useNetwork, useTheme } from "../engine/hooks"; +import { useTheme } from "../engine/hooks"; import { useDeleteCurrentAccount } from "../engine/hooks/appstate/useDeleteCurrentAccount"; import { StatusBar } from "expo-status-bar"; -import { useAppAuthMandatory } from "../engine/hooks/settings"; import { getAppState } from "../storage/appState"; import { getHasHoldersProducts } from "../engine/hooks/holders/useHasHoldersProducts"; @@ -29,10 +28,8 @@ function hasHoldersProductsOnDevice(isTestnet: boolean) { export const LogoutFragment = fragment(() => { const theme = useTheme(); - const { isTestnet } = useNetwork(); const safeArea = useSafeAreaInsets(); const navigation = useTypedNavigation(); - const [, setMandatoryAuth] = useAppAuthMandatory(); const { showActionSheetWithOptions } = useActionSheet(); const onAccountDeleted = useDeleteCurrentAccount(); @@ -63,16 +60,7 @@ export const LogoutFragment = fragment(() => { const onLogout = useCallback(async () => { onAccountDeleted(); - - // Check if there are any holders products left on other accounts - const hasHoldersProductsLeft = hasHoldersProductsOnDevice(isTestnet); - - // if not, disable mandatory auth - if (!hasHoldersProductsLeft) { - setMandatoryAuth(false); - } - - }, [isTestnet, onAccountDeleted]); + }, [onAccountDeleted]); const showLogoutActSheet = useCallback(() => { if (isShown) { diff --git a/app/fragments/SecurityFragment.tsx b/app/fragments/SecurityFragment.tsx index 8e19ecd15..9ee5bf2af 100644 --- a/app/fragments/SecurityFragment.tsx +++ b/app/fragments/SecurityFragment.tsx @@ -6,7 +6,7 @@ import { fragment } from "../fragment" import { t } from "../i18n/t" import { BiometricsState, PasscodeState } from "../storage/secureStorage" import { useTypedNavigation } from "../utils/useTypedNavigation" -import { useSelectedAccount, useTheme } from '../engine/hooks'; +import { useAppState, useHasHoldersProducts, useNetwork, useSelectedAccount, useTheme } from '../engine/hooks'; import { useEffect, useMemo, useState } from "react" import { DeviceEncryption, getDeviceEncryption } from "../storage/getDeviceEncryption" import { Ionicons } from '@expo/vector-icons'; @@ -17,26 +17,35 @@ import { usePasscodeState } from '../engine/hooks' import { useBiometricsState } from '../engine/hooks' import { useSetBiometricsState } from "../engine/hooks/appstate/useSetBiometricsState" import { ScreenHeader } from "../components/ScreenHeader" -import { useAppAuthMandatory, useLockAppWithAuthState } from "../engine/hooks/settings" +import { useLockAppWithAuthState } from "../engine/hooks/settings" import { StatusBar } from "expo-status-bar" +import { Typography } from "../components/styles" +import { getAppState } from "../storage/appState" import TouchAndroid from '@assets/ic_touch_and.svg'; import FaceIos from '@assets/ic_face_id.svg'; -import { Typography } from "../components/styles" +import { getHasHoldersProducts } from "../engine/hooks/holders/useHasHoldersProducts" export const SecurityFragment = fragment(() => { const safeArea = useSafeAreaInsets(); const navigation = useTypedNavigation(); const authContext = useKeysAuth(); const theme = useTheme(); + const { isTestnet } = useNetwork(); const passcodeState = usePasscodeState(); const biometricsState = useBiometricsState(); const setBiometricsState = useSetBiometricsState(); - const [mandatoryAuth,] = useAppAuthMandatory(); + const selectedAddress = useSelectedAccount()!.address.toString({ testOnly: isTestnet }); + const selectedAccountIndex = useAppState().selected; + const accHasHoldersProducts = useHasHoldersProducts(selectedAddress); const [deviceEncryption, setDeviceEncryption] = useState(); const [lockAppWithAuthState, setLockAppWithAuthState] = useLockAppWithAuthState(); - const canToggleAppAuth = !(mandatoryAuth && lockAppWithAuthState); + // 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) { @@ -229,10 +238,9 @@ export const SecurityFragment = fragment(() => { } })(); }} - disabled={!canToggleAppAuth} /> - {!canToggleAppAuth && ( + {deviceHasHoldersProducts && ( {t('mandatoryAuth.settingsDescription')} diff --git a/app/fragments/secure/MandatoryAuthSetupFragment.tsx b/app/fragments/secure/MandatoryAuthSetupFragment.tsx index b131c5638..27e19f20a 100644 --- a/app/fragments/secure/MandatoryAuthSetupFragment.tsx +++ b/app/fragments/secure/MandatoryAuthSetupFragment.tsx @@ -11,10 +11,10 @@ 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, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { ScrollView } from "react-native-gesture-handler"; import { RoundButton } from "../../components/RoundButton"; -import { useAppAuthMandatory, useLockAppWithAuthState } from "../../engine/hooks/settings"; +import { useLockAppWithAuthState } from "../../engine/hooks/settings"; import { useToaster } from "../../components/toast/ToastProvider"; import WarningIcon from '@assets/ic-warning-banner.svg'; @@ -30,7 +30,6 @@ export const MandatoryAuthSetupFragment = fragment(() => { const theme = useTheme(); const { callback } = useParams(); const [secured, setSecured] = useState(false); - const [mandatoryAuth, setMandatoryAuth] = useAppAuthMandatory(); const [lockAppWithAuth, setLockAppWithAuth] = useLockAppWithAuthState(); const onCallback = (ok: boolean) => { @@ -46,7 +45,6 @@ export const MandatoryAuthSetupFragment = fragment(() => { setLockAppWithAuth(true); } - setMandatoryAuth(true); onCallback(true); } catch (reason) { @@ -56,7 +54,7 @@ export const MandatoryAuthSetupFragment = fragment(() => { toaster.show({ message: t('products.tonConnect.errors.unknown'), type: 'default' }); } } - }, [mandatoryAuth, lockAppWithAuth]); + }, [lockAppWithAuth]); return ( = { 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 mandatory as the app displays banking products' + settingsDescription: 'Authentication request is required as the app displays banking products. Sensative data will be hidden until you turn the authentication on', } }; diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index c57dc293d..8587e3157 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -1076,7 +1076,7 @@ const schema: PrepareSchema = { "alert": 'Сохраните 24 секретных слова (Seed фразу) от вашего кошелька. Это поможет вам восстановить доступ, если вы потеряете телефон или забудете пин-код.', "confirmDescription": '24 секретных слова записаны и хранятся в надежном месте', "action": 'Включить', - "settingsDescription": 'Авторизация обязательна, так как в приложении отображаются банковские продукты' + "settingsDescription": 'Авторизация обязательна для отображения банковских продуктов. Чувствительные данные будут скрыты, пока вы не включите авторизацию', } }; diff --git a/app/utils/useTypedNavigation.ts b/app/utils/useTypedNavigation.ts index 8ec8a3d51..b70e8b692 100644 --- a/app/utils/useTypedNavigation.ts +++ b/app/utils/useTypedNavigation.ts @@ -15,7 +15,7 @@ import { StakingFragmentParams } from '../fragments/staking/StakingFragment'; import { PendingTxPreviewParams } from '../fragments/wallet/PendingTxPreviewFragment'; import { HomeFragmentProps } from '../fragments/HomeFragment'; import { MandatoryAuthSetupParams } from '../fragments/secure/MandatoryAuthSetupFragment'; -import { getLockAppWithAuthMandatory, getLockAppWithAuthState } from '../engine/state/lockAppWithAuthState'; +import { getLockAppWithAuthState } from '../engine/state/lockAppWithAuthState'; import { getHasHoldersProducts } from '../engine/hooks/holders/useHasHoldersProducts'; import { getCurrentAddress } from '../storage/appState'; import { Platform } from 'react-native'; @@ -46,11 +46,10 @@ export function typedNavigateAndReplaceAll(src: Base, name: string, params?: any function shouldTurnAuthOn(isTestnet: boolean) { const isAppAuthOn = getLockAppWithAuthState(); - const isMandatoryAuthOn = getLockAppWithAuthMandatory(); const currentAccount = getCurrentAddress(); const hasAccounts = getHasHoldersProducts(currentAccount.address.toString({ testOnly: isTestnet })); - return (!isAppAuthOn || !isMandatoryAuthOn) && hasAccounts; + return !isAppAuthOn && hasAccounts; } export class TypedNavigation { From 9b45441b9f0e5192e39d2037d7c3909c5378251c Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 18 Jul 2024 14:40:21 +0300 Subject: [PATCH 51/54] fix: typo --- app/i18n/i18n_en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 02b834d4b..11a7aae53 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -1076,7 +1076,7 @@ const schema: PrepareSchema = { 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. Sensative data will be hidden until you turn the authentication on', + settingsDescription: 'Authentication request is required as the app displays banking products. Sensitive data will be hidden until you turn the authentication on', } }; From 58337230126e2878085ab883a707c9ec5884558c Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 18 Jul 2024 14:43:57 +0300 Subject: [PATCH 52/54] fix: code style --- app/components/products/HoldersAccountCard.tsx | 2 +- app/components/products/HoldersPrepaidCard.tsx | 2 +- app/fragments/LogoutFragment.tsx | 8 ++------ app/fragments/SecurityFragment.tsx | 3 ++- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/components/products/HoldersAccountCard.tsx b/app/components/products/HoldersAccountCard.tsx index 87ca94ebf..376afe519 100644 --- a/app/components/products/HoldersAccountCard.tsx +++ b/app/components/products/HoldersAccountCard.tsx @@ -22,7 +22,7 @@ const cardImages = { } export const HoldersAccountCard = memo(({ card, theme }: { card: GeneralHoldersCard, theme: ThemeType }) => { - const [lockAppWithAuth,] = useLockAppWithAuthState(); + 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'; diff --git a/app/components/products/HoldersPrepaidCard.tsx b/app/components/products/HoldersPrepaidCard.tsx index 204846050..90461f302 100644 --- a/app/components/products/HoldersPrepaidCard.tsx +++ b/app/components/products/HoldersPrepaidCard.tsx @@ -32,7 +32,7 @@ export const HoldersPrepaidCard = memo((props: { holdersAccStatus?: HoldersAccountStatus, onBeforeOpen?: () => void }) => { - const [lockAppWithAuth,] = useLockAppWithAuthState(); + const [lockAppWithAuth] = useLockAppWithAuthState(); const card = props.card; const swipableRef = useRef(null); const theme = useTheme(); diff --git a/app/fragments/LogoutFragment.tsx b/app/fragments/LogoutFragment.tsx index cc04cb59a..088352a49 100644 --- a/app/fragments/LogoutFragment.tsx +++ b/app/fragments/LogoutFragment.tsx @@ -58,10 +58,6 @@ export const LogoutFragment = fragment(() => { const [isShown, setIsShown] = useState(false); - const onLogout = useCallback(async () => { - onAccountDeleted(); - }, [onAccountDeleted]); - const showLogoutActSheet = useCallback(() => { if (isShown) { return; @@ -81,7 +77,7 @@ export const LogoutFragment = fragment(() => { }, (selectedIndex?: number) => { switch (selectedIndex) { case 1: - onLogout(); + onAccountDeleted(); break; case cancelButtonIndex: // Canceled @@ -90,7 +86,7 @@ export const LogoutFragment = fragment(() => { } setIsShown(false); }); - }, [isShown, onLogout]); + }, [isShown, onAccountDeleted]); return ( { const safeArea = useSafeAreaInsets(); @@ -44,6 +44,7 @@ export const SecurityFragment = fragment(() => { // 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]); From bea4d8b65a1d9b9cf95aece9e799cc9987fa77ba Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 19 Jul 2024 11:03:42 +0300 Subject: [PATCH 53/54] fix: mandatory auth navigation after callback --- app/utils/useTypedNavigation.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/app/utils/useTypedNavigation.ts b/app/utils/useTypedNavigation.ts index b70e8b692..10592b160 100644 --- a/app/utils/useTypedNavigation.ts +++ b/app/utils/useTypedNavigation.ts @@ -171,12 +171,7 @@ export class TypedNavigation { if (shouldTurnAuthOn(isTestnet)) { const callback = (success: boolean) => { if (success) { // navigate only if auth is set up - if (Platform.OS === 'android') { - this.replace('HoldersLanding', { endpoint, onEnrollType }); - } else { - this.goBack(); // close modal - this.navigate('HoldersLanding', { endpoint, onEnrollType }) - } + this.navigate('HoldersLanding', { endpoint, onEnrollType }) } } this.navigateMandatoryAuthSetup({ callback }); @@ -189,12 +184,7 @@ export class TypedNavigation { if (shouldTurnAuthOn(isTestnet)) { const callback = (success: boolean) => { if (success) { // navigate only if auth is set up - if (Platform.OS === 'android') { - this.replace('Holders', params); - } else { - this.goBack(); // close modal - this.navigate('Holders', params); - } + this.navigate('Holders', params); } } this.navigateMandatoryAuthSetup({ callback }); From 79e4a8a7da16868fad73e2e5c4a0e647d386e9f4 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 19 Jul 2024 15:09:12 +0300 Subject: [PATCH 54/54] fix: useless ?? statement --- app/components/webview/DAppWebView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index abac4ff85..655afcbce 100644 --- a/app/components/webview/DAppWebView.tsx +++ b/app/components/webview/DAppWebView.tsx @@ -375,7 +375,7 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar ${props.useEmitter ? emitterAPI : ''} ${props.useAuthApi ? authAPI({ lastAuthTime: getLastAuthTimestamp(), - isSecured: getLockAppWithAuthState() ?? false + isSecured: getLockAppWithAuthState() }) : ''} ${props.injectedJavaScriptBeforeContentLoaded ?? ''} (() => {