diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8e3ed6aa4..6bb64a51b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -10,7 +10,7 @@ jobs: matrix: os: [macos-13] node-version: [18.18.0] - ruby-version: [3.2] + ruby-version: [2.7] xcode: [15.0.1] runs-on: ${{ matrix.os }} @@ -32,9 +32,6 @@ jobs: max_attempts: 3 command: yarn - - name: Install pods dependencies - run: yarn pods - - name: Set up Ruby and Gemfile dependencies uses: ruby/setup-ruby@v1 with: @@ -42,6 +39,18 @@ jobs: bundler-cache: true working-directory: './packages/mobile' + - name: Cache Pods directory + uses: actions/cache@v3 + with: + path: ./packages/mobile/ios/Pods + key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods- + + - name: Install pods dependencies + working-directory: './packages/mobile/ios' + run: bundle exec pod install + - name: Decode signing certificate into a file working-directory: './packages/mobile/ios' env: @@ -83,7 +92,7 @@ jobs: matrix: os: [macos-13] node-version: [18.18.0] - ruby-version: [3.2] + ruby-version: [2.7] java-version: [11.0.12] runs-on: ${{ matrix.os }} @@ -159,7 +168,7 @@ jobs: matrix: os: [macos-13] node-version: [18.18.0] - ruby-version: [3.2] + ruby-version: [2.7] java-version: [11.0.12] runs-on: ${{ matrix.os }} diff --git a/packages/@core-js/src/TonAPI/HttpClient.ts b/packages/@core-js/src/TonAPI/HttpClient.ts index 46897857d..08b50db7a 100644 --- a/packages/@core-js/src/TonAPI/HttpClient.ts +++ b/packages/@core-js/src/TonAPI/HttpClient.ts @@ -10,7 +10,7 @@ type CancelToken = Symbol | string | number; export type HttpClientOptions = { baseUrl: string | (() => string); - token?: string | (() => string); + baseHeaders?: { [key: string]: string } | (() => { [key: string]: string }); }; export class HttpClient { @@ -143,10 +143,10 @@ export class HttpClient { typeof this.options.baseUrl === 'function' ? this.options.baseUrl() : this.options.baseUrl; - const token = - typeof this.options.token === 'function' - ? this.options.token() - : this.options.token; + const baseHeaders = + typeof this.options.baseHeaders === 'function' + ? this.options.baseHeaders() + : this.options.baseHeaders; const response = await this.customFetch( `${baseUrl}${path}${queryString ? `?${queryString}` : ''}`, @@ -154,7 +154,7 @@ export class HttpClient { method, headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + ...(baseHeaders ?? {}), ...(headers ?? {}), }, signal: cancelToken ? this.createAbortSignal(cancelToken) : null, diff --git a/packages/@core-js/src/TonAPI/TonAPIGenerated.ts b/packages/@core-js/src/TonAPI/TonAPIGenerated.ts index ea60b3957..a73a3cd2a 100644 --- a/packages/@core-js/src/TonAPI/TonAPIGenerated.ts +++ b/packages/@core-js/src/TonAPI/TonAPIGenerated.ts @@ -337,6 +337,11 @@ export interface CreditPhase { export interface ActionPhase { /** @example true */ success: boolean; + /** + * @format int32 + * @example 5 + */ + result_code: number; /** * @format int32 * @example 5 @@ -667,8 +672,8 @@ export interface ValidatorsSet { utime_until: number; total: number; main: number; - /** @format int64 */ - total_weight?: number; + /** @example "1152921504606846800" */ + total_weight?: string; list: { public_key: string; /** @format int64 */ @@ -804,8 +809,9 @@ export interface BlockchainRawAccount { * @example 123456789 */ last_transaction_lt: number; - /** @example "active" */ - status: string; + /** @example "088b436a846d92281734236967970612f87fbd64a2cd3573107948379e8e4161" */ + last_transaction_hash?: string; + status: AccountStatus; storage: AccountStorageInfo; } @@ -823,8 +829,7 @@ export interface Account { * @example 123456789 */ last_activity: number; - /** @example "active" */ - status: string; + status: AccountStatus; interfaces?: string[]; /** @example "Ton foundation" */ name?: string; @@ -1357,6 +1362,8 @@ export interface Action { JettonSwap?: JettonSwapAction; SmartContractExec?: SmartContractAction; DomainRenew?: DomainRenewAction; + InscriptionTransfer?: InscriptionTransferAction; + InscriptionMint?: InscriptionMintAction; /** shortly describes what this action is about. */ simple_preview: ActionSimplePreview; } @@ -1402,6 +1409,42 @@ export interface DomainRenewAction { renewer: AccountAddress; } +export interface InscriptionMintAction { + recipient: AccountAddress; + /** + * amount in minimal particles + * @example "123456789" + */ + amount: string; + /** @example "ton20" */ + type: InscriptionMintActionTypeEnum; + /** @example "nano" */ + ticker: string; + /** @example 9 */ + decimals: number; +} + +export interface InscriptionTransferAction { + sender: AccountAddress; + recipient: AccountAddress; + /** + * amount in minimal particles + * @example "123456789" + */ + amount: string; + /** + * @example "Hi! This is your salary. + * From accounting with love." + */ + comment?: string; + /** @example "ton20" */ + type: InscriptionTransferActionTypeEnum; + /** @example "nano" */ + ticker: string; + /** @example 9 */ + decimals: number; +} + export interface NftItemTransferAction { sender?: AccountAddress; recipient?: AccountAddress; @@ -2292,6 +2335,8 @@ export enum ActionTypeEnum { ElectionsRecoverStake = 'ElectionsRecoverStake', ElectionsDepositStake = 'ElectionsDepositStake', DomainRenew = 'DomainRenew', + InscriptionTransfer = 'InscriptionTransfer', + InscriptionMint = 'InscriptionMint', Unknown = 'Unknown', } @@ -2301,6 +2346,18 @@ export enum ActionStatusEnum { Failed = 'failed', } +/** @example "ton20" */ +export enum InscriptionMintActionTypeEnum { + Ton20 = 'ton20', + Gram20 = 'gram20', +} + +/** @example "ton20" */ +export enum InscriptionTransferActionTypeEnum { + Ton20 = 'ton20', + Gram20 = 'gram20', +} + export enum AuctionBidActionAuctionTypeEnum { DNSTon = 'DNS.ton', DNSTg = 'DNS.tg', @@ -2345,6 +2402,7 @@ export interface GetBlockchainAccountTransactionsParams { before_lt?: number; /** * @format int32 + * @min 1 * @max 1000 * @default 100 * @example 100 @@ -2359,7 +2417,14 @@ export interface GetBlockchainAccountTransactionsParams { export interface ExecGetMethodForBlockchainAccountParams { /** - * Supported values: NaN, Null, 10-base digits for tiny int, 0x-prefixed hex digits for int257, all forms of addresses for slice, single-root base64-encoded BOC for cell + * Supported values: + * "NaN" for NaN type, + * "Null" for Null type, + * 10-base digits for tiny int type (Example: 100500), + * 0x-prefixed hex digits for int257 (Example: 0xfa01d78381ae32), + * all forms of addresses for slice type (Example: 0:6e731f2e28b73539a7f85ac47ca104d5840b229351189977bb6151d36b5e3f5e), + * single-root base64-encoded BOC for cell (Example: "te6ccgEBAQEAAgAAAA=="), + * single-root hex-encoded BOC for slice (Example: b5ee9c72010101010002000000) * @example ["0:9a33970f617bcd71acf2cd28357c067aa31859c02820d8f01d74c88063a8f4d8"] */ args?: string[]; @@ -2404,6 +2469,7 @@ export interface GetAccountJettonsHistoryParams { */ before_lt?: number; /** + * @min 1 * @max 1000 * @example 100 */ @@ -2433,6 +2499,7 @@ export interface GetAccountJettonHistoryByIdParams { */ before_lt?: number; /** + * @min 1 * @max 1000 * @example 100 */ @@ -2466,11 +2533,15 @@ export interface GetAccountNftItemsParams { */ collection?: string; /** + * @min 1 * @max 1000 * @default 1000 */ limit?: number; - /** @default 0 */ + /** + * @min 0 + * @default 0 + */ offset?: number; /** * Selling nft items in ton implemented usually via transfer items to special selling account. This option enables including items which owned not directly. @@ -2492,6 +2563,7 @@ export interface GetAccountNftHistoryParams { */ before_lt?: number; /** + * @min 1 * @max 1000 * @example 100 */ @@ -2531,6 +2603,7 @@ export interface GetAccountEventsParams { */ before_lt?: number; /** + * @min 1 * @max 1000 * @example 100 */ @@ -2572,6 +2645,7 @@ export interface GetAccountEventParams { export interface GetAccountTracesParams { /** + * @min 1 * @max 1000 * @default 100 * @example 100 @@ -2635,6 +2709,7 @@ export interface GetAllAuctionsParams { export interface GetNftCollectionsParams { /** * @format int32 + * @min 1 * @max 1000 * @default 100 * @example 15 @@ -2642,6 +2717,7 @@ export interface GetNftCollectionsParams { limit?: number; /** * @format int32 + * @min 0 * @default 0 * @example 10 */ @@ -2650,11 +2726,15 @@ export interface GetNftCollectionsParams { export interface GetItemsFromCollectionParams { /** + * @min 1 * @max 1000 * @default 1000 */ limit?: number; - /** @default 0 */ + /** + * @min 0 + * @default 0 + */ offset?: number; /** * account ID @@ -2671,6 +2751,7 @@ export interface GetNftHistoryByIdParams { */ before_lt?: number; /** + * @min 1 * @max 1000 * @example 100 */ @@ -2694,11 +2775,15 @@ export interface GetNftHistoryByIdParams { export interface GetAccountInscriptionsParams { /** + * @min 1 * @max 1000 * @default 1000 */ limit?: number; - /** @default 0 */ + /** + * @min 0 + * @default 0 + */ offset?: number; /** * account ID @@ -2707,6 +2792,50 @@ export interface GetAccountInscriptionsParams { accountId: string; } +export interface GetAccountInscriptionsHistoryParams { + /** + * omit this parameter to get last events + * @format int64 + * @example 25758317000002 + */ + before_lt?: number; + /** + * @min 1 + * @max 1000 + * @default 100 + * @example 100 + */ + limit?: number; + /** + * account ID + * @example "0:97264395BD65A255A429B11326C84128B7D70FFED7949ABAE3036D506BA38621" + */ + accountId: string; +} + +export interface GetAccountInscriptionsHistoryByTickerParams { + /** + * omit this parameter to get last events + * @format int64 + * @example 25758317000002 + */ + before_lt?: number; + /** + * @min 1 + * @max 1000 + * @default 100 + * @example 100 + */ + limit?: number; + /** + * account ID + * @example "0:97264395BD65A255A429B11326C84128B7D70FFED7949ABAE3036D506BA38621" + */ + accountId: string; + /** @example "nano" */ + ticker: string; +} + export interface GetInscriptionOpTemplateParams { /** @example "ton20" */ type: TypeEnum; @@ -2747,6 +2876,7 @@ export enum GetInscriptionOpTemplateParams1OperationEnum { export interface GetJettonsParams { /** * @format int32 + * @min 1 * @max 1000 * @default 100 * @example 15 @@ -2754,6 +2884,7 @@ export interface GetJettonsParams { limit?: number; /** * @format int32 + * @min 0 * @default 0 * @example 10 */ @@ -2762,11 +2893,15 @@ export interface GetJettonsParams { export interface GetJettonHoldersParams { /** + * @min 1 * @max 1000 * @default 1000 */ limit?: number; - /** @default 0 */ + /** + * @min 0 + * @default 0 + */ offset?: number; /** * account ID @@ -2816,6 +2951,13 @@ export interface GetChartRatesParams { * @example 1668436763 */ end_date?: number; + /** + * @format int + * @min 0 + * @max 200 + * @default 200 + */ + points_count?: number; } export interface GetRawMasterchainInfoExtParams { @@ -4133,25 +4275,6 @@ export class TonAPIGenerated { format: 'json', ...params, }), - - /** - * @description Get all inscriptions by owner address - * - * @tags Inscriptions - * @name GetAccountInscriptions - * @request GET:/v2/accounts/{account_id}/inscriptions - */ - getAccountInscriptions: ( - { accountId, ...query }: GetAccountInscriptionsParams, - params: RequestParams = {}, - ) => - this.http.request({ - path: `/v2/accounts/${accountId}/inscriptions`, - method: 'GET', - query: query, - format: 'json', - ...params, - }), }; dns = { /** @@ -4342,7 +4465,45 @@ export class TonAPIGenerated { }), /** - * @description return comment for making operation with instrospection. please don't use it if you don't know what you are doing + * @description Get the transfer inscriptions history for account. It's experimental API and can be dropped in the future. + * + * @tags Inscriptions + * @name GetAccountInscriptionsHistory + * @request GET:/v2/experimental/accounts/{account_id}/inscriptions/history + */ + getAccountInscriptionsHistory: ( + { accountId, ...query }: GetAccountInscriptionsHistoryParams, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/v2/experimental/accounts/${accountId}/inscriptions/history`, + method: 'GET', + query: query, + format: 'json', + ...params, + }), + + /** + * @description Get the transfer inscriptions history for account. It's experimental API and can be dropped in the future. + * + * @tags Inscriptions + * @name GetAccountInscriptionsHistoryByTicker + * @request GET:/v2/experimental/accounts/{account_id}/inscriptions/{ticker}/history + */ + getAccountInscriptionsHistoryByTicker: ( + { accountId, ticker, ...query }: GetAccountInscriptionsHistoryByTickerParams, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/v2/experimental/accounts/${accountId}/inscriptions/${ticker}/history`, + method: 'GET', + query: query, + format: 'json', + ...params, + }), + + /** + * @description return comment for making operation with inscription. please don't use it if you don't know what you are doing * * @tags Inscriptions * @name GetInscriptionOpTemplate diff --git a/packages/@core-js/src/TonAPI/context.tsx b/packages/@core-js/src/TonAPI/context.tsx deleted file mode 100644 index ba159d430..000000000 --- a/packages/@core-js/src/TonAPI/context.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { createContext, useContext, memo } from 'react'; -import { TonAPI } from './TonAPI'; - -const TonAPIContext = createContext(null); - -interface TonAPIProviderProps { - children: React.ReactNode; - tonapi: TonAPI; -} - -export const TonAPIProvider = memo(({ children, tonapi }: TonAPIProviderProps) => ( - {children} -)); - -export const useTonAPI = () => { - const tonapi = useContext(TonAPIContext); - - if (tonapi === null) { - throw new Error('Wrap App TonAPIProvider'); - } - - return tonapi; -}; diff --git a/packages/@core-js/src/TonAPI/index.ts b/packages/@core-js/src/TonAPI/index.ts index 95f7ae003..c0c58ac58 100644 --- a/packages/@core-js/src/TonAPI/index.ts +++ b/packages/@core-js/src/TonAPI/index.ts @@ -1,3 +1,2 @@ -export { useTonAPI, TonAPIProvider } from './context'; export { TonAPI } from './TonAPI'; export * from './TonAPIGenerated'; diff --git a/packages/@core-js/src/Tonkeeper.ts b/packages/@core-js/src/Tonkeeper.ts deleted file mode 100644 index daf460fac..000000000 --- a/packages/@core-js/src/Tonkeeper.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { ServerSentEvents } from './declarations/ServerSentEvents'; -import { createTronOwnerAddress } from './utils/tronUtils'; -import { Storage } from './declarations/Storage'; -import { Wallet, WalletNetwork } from './Wallet'; -import { Address } from './formatters/Address'; -import { Vault } from './declarations/Vault'; -import { QueryClient } from 'react-query'; -import { TronAPI } from './TronAPI'; -import { TonAPI } from './TonAPI'; -import { BatteryAPI } from './BatteryAPI'; -import { signProofForTonkeeper } from './utils/tonProof'; -import { storeStateInit } from '@ton/ton'; -import nacl from 'tweetnacl'; -import { batteryState } from './managers/BatteryManager'; -import { beginCell } from '@ton/core'; -import { ContractService, WalletVersion } from './service'; - -class PermissionsManager { - public notifications = true; - public biometry = true; -} - -type TonkeeperOptions = { - queryClient: QueryClient; - sse: ServerSentEvents; - tronapi: TronAPI; - storage: Storage; - tonapi: TonAPI; - batteryapi: BatteryAPI; - vault: Vault; -}; - -type SecuritySettings = { - biometryEnabled: boolean; - hiddenBalances: boolean; - locked: boolean; -}; - -export class Tonkeeper { - public permissions: PermissionsManager; - public wallet!: Wallet; - public wallets = []; - - public securitySettings: SecuritySettings = { - biometryEnabled: false, - hiddenBalances: false, - locked: false, - }; - - private sse: ServerSentEvents; - private storage: Storage; - private queryClient: QueryClient; - private tronapi: TronAPI; - private tonapi: TonAPI; - private batteryapi: BatteryAPI; - private vault: Vault; - - constructor(options: TonkeeperOptions) { - this.queryClient = options.queryClient; - this.storage = options.storage; - this.tronapi = options.tronapi; - this.tonapi = options.tonapi; - this.batteryapi = options.batteryapi; - this.vault = options.vault; - this.sse = options.sse; - - this.permissions = new PermissionsManager(); - } - - // TODO: for temp, rewrite it when ton wallet will it be moved here; - // init() must be called when app starts - public async init( - address: string, - isTestnet: boolean, - tronAddress: string, - tonProof: string, - walletStateInit: string, - // TODO: remove after transition to UQ address format - bounceable = true, - ) { - try { - this.destroy(); - if (address) { - if (Address.isValid(address)) { - this.wallet = new Wallet( - this.queryClient, - this.tonapi, - this.tronapi, - this.batteryapi, - this.vault, - this.sse, - this.storage, - { - network: isTestnet ? WalletNetwork.testnet : WalletNetwork.mainnet, - tronAddress: tronAddress, - address: address, - bounceable, - tonProof, - }, - ); - - this.rehydrate(); - this.preload(); - } - } - } catch (err) { - console.log(err); - } - - //Load data from storage - // const info = await this.storage.load('tonkeeper'); - // if (info) { - // this.locked = info.locked; - // // - // // - // } - // const locked = await this.storage.getItem('locked'); - // this.securitySettings.biometryEnabled = - // (await this.storage.getItem('biometry_enabled')) === 'yes'; - // if (locked === null || Boolean(locked) === true) { - // this.securitySettings.locked = true; - // // await this.wallet.getPrivateKey(); - // } - } - - public tronStorageKey = 'temp-tron-address'; - public tonProofStorageKey = 'temp-ton-proof'; // TODO: rewrite it with multi-accounts - - public async load() { - try { - const tonProof = await this.storage.getItem(this.tonProofStorageKey); - const tronAddress = await this.storage.getItem(this.tronStorageKey); - return { - tronAddress: tronAddress ? JSON.parse(tronAddress) : null, - tonProof: tonProof ? JSON.parse(tonProof) : null, - }; - } catch (err) { - console.error('[tk:load]', err); - return { tronAddress: null, tonProof: null }; - } - } - - public async generateTronAddress(tonPrivateKey: Uint8Array) { - return; - try { - const ownerAddress = await createTronOwnerAddress(tonPrivateKey); - const tronWallet = await this.tronapi.wallet.getWallet(ownerAddress); - - const tronAddress = { - proxy: tronWallet.address, - owner: ownerAddress, - }; - - await this.storage.setItem(this.tronStorageKey, JSON.stringify(tronAddress)); - - return tronAddress; - } catch (err) { - console.error('[Tonkeeper]', err); - } - } - - public async obtainProofToken(keyPair: nacl.SignKeyPair) { - const contract = ContractService.getWalletContract( - WalletVersion.v4R2, - Buffer.from(keyPair.publicKey), - 0, - ); - const stateInitCell = beginCell().store(storeStateInit(contract.init)).endCell(); - const rawAddress = contract.address.toRawString(); - - try { - const { payload } = await this.tonapi.tonconnect.getTonConnectPayload(); - const proof = await signProofForTonkeeper( - rawAddress, - keyPair.secretKey, - payload, - stateInitCell.toBoc({ idx: false }).toString('base64'), - ); - const { token } = await this.tonapi.wallet.tonConnectProof(proof); - - await this.storage.setItem(this.tonProofStorageKey, JSON.stringify(token)); - return token; - } catch (err) { - await this.storage.removeItem(this.tonProofStorageKey); - } - } - - // Update all data, - // Invoke in background after hide splash screen - private preload() { - this.wallet.activityList.preload(); - this.wallet.tonInscriptions.preload(); - this.wallet.battery.fetchBalance(); - // TODO: - this.wallet.subscriptions.prefetch(); - this.wallet.balances.prefetch(); - this.wallet.nfts.prefetch(); - } - - public rehydrate() { - this.wallet.jettonActivityList.rehydrate(); - this.wallet.tonActivityList.rehydrate(); - this.wallet.activityList.rehydrate(); - this.wallet.battery.rehydrate(); - } - - public async lock() { - this.securitySettings.locked = true; - return this.updateSecuritySettings(); - } - - public async unlock() { - this.securitySettings.locked = false; - return this.updateSecuritySettings(); - } - - public showBalances() { - this.securitySettings.hiddenBalances = false; - return this.updateSecuritySettings(); - } - - public hideBalances() { - this.securitySettings.hiddenBalances = true; - return this.updateSecuritySettings(); - } - - public enableBiometry() { - this.securitySettings.biometryEnabled = false; - // this.enableBiometry() - } - - public disableBiometry() { - this.securitySettings.biometryEnabled = false; - for (let wallet of this.wallets) { - // this.vault.removeBiometry(wallet.pubkey); - } - - // this.notifyUI(); - } - - private async updateSecuritySettings() { - // this.notifyUI(); - return this.storage.set('securitySettings', this.securitySettings); - } - - public destroy() { - batteryState.clear(); - this.wallet?.destroy(); - this.queryClient.clear(); - this.wallet = null!; - } -} diff --git a/packages/@core-js/src/TronService.ts b/packages/@core-js/src/TronService.ts index a1f7472b0..efe95fdc9 100644 --- a/packages/@core-js/src/TronService.ts +++ b/packages/@core-js/src/TronService.ts @@ -1,12 +1,12 @@ -import BigNumber from 'bignumber.js'; import { ethers } from 'ethers'; -import { WalletContext } from './Wallet'; import { AmountFormatter } from './utils/AmountFormatter'; import { hashRequest, tonPKToTronPK } from './utils/tronUtils'; import { EstimatePayload, RequestData } from './TronAPI/TronAPIGenerated'; +import { WalletAddress } from '@tonkeeper/mobile/src/wallet/WalletTypes'; +import { TronAPI } from './TronAPI'; export class TronService { - constructor(private ctx: WalletContext) {} + constructor(private address: WalletAddress, private tronapi: TronAPI) {} public async estimate(params: { to: string; @@ -26,8 +26,7 @@ export class TronService { tokenAddress: string; }) { const response = await fetch( - `https://tron.tonkeeper.com/api/v2/wallet/${this.ctx.address.tron - ?.owner!}/estimate`, + `https://tron.tonkeeper.com/api/v2/wallet/${this.address.tron?.owner!}/estimate`, { method: 'POST', headers: { @@ -47,8 +46,8 @@ export class TronService { ); const result = await response.json(); - // const data = await this.ctx.tronapi.wallet.getEstimation( - // this.ctx.address.tron?.owner!, + // const data = await this.tronapi.wallet.getEstimation( + // this.address.tron?.owner!, // { // lifeTime: Math.floor(Date.now() / 1000) + 600, // messages: [ @@ -64,13 +63,13 @@ export class TronService { } public async send(privateKey: Uint8Array, request: RequestData) { - const settings = await this.ctx.tronapi.settings.getSettings(); + const settings = await this.tronapi.settings.getSettings(); const hash = hashRequest(request, settings.walletImplementation, settings.chainId); const signingKey = new ethers.SigningKey('0x' + (await tonPKToTronPK(privateKey))); const signature = signingKey.sign(hash).serialized; - const ownerAddress = this.ctx.address.tron?.owner!; + const ownerAddress = this.address.tron?.owner!; const response = await fetch( `https://tron.tonkeeper.com/api/v2/wallet/${ownerAddress}/publish`, diff --git a/packages/@core-js/src/Wallet.ts b/packages/@core-js/src/Wallet.ts deleted file mode 100644 index d0fd994e2..000000000 --- a/packages/@core-js/src/Wallet.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { QueryClient } from 'react-query'; -import { Address, AddressFormats } from './formatters/Address'; -import { TonAPI } from './TonAPI'; -import { Vault } from './declarations/Vault'; - -import { ActivityList } from './Activity/ActivityList'; -import { NftsManager } from './managers/NftsManager'; -import { EventSourceListener, ServerSentEvents, IStorage } from './Tonkeeper'; -import { SubscriptionsManager } from './managers/SubscriptionsManager'; - -import { BalancesManager } from './managers/BalancesManager'; -import { TronAPI } from './TronAPI'; -import { TronService } from './TronService'; -import { ActivityLoader } from './Activity/ActivityLoader'; -import { TonActivityList } from './Activity/TonActivityList'; -import { JettonActivityList } from './Activity/JettonActivityList'; -import { BatteryAPI } from './BatteryAPI'; -import { BatteryManager } from './managers/BatteryManager'; -import { TonInscriptions } from './managers/TonInscriptions'; - -export enum WalletNetwork { - mainnet = -239, - testnet = -3, -} - -export enum WalletKind { - Regular = 'Regular', - Lockup = 'Lockup', - WatchOnly = 'WatchOnly', -} - -export type WalletIdentity = { - network: WalletNetwork; - kind: WalletKind; - stateInit: string; - tonProof: string; - // id: string; -}; - -enum Currency { - USD = 'USD', -} - -enum WalletContractVersion { - v4R1 = 'v3R2', - v3R2 = 'v3R2', - v4R2 = 'v4R2', - NA = 'NA', -} - -type WalletInfo = { - identity: Pick; - currency: Currency; - label: string; -}; - -type TronAddresses = { - proxy: string; - owner: string; -}; - -type RawAddress = string; - -export type WalletAddresses = { - tron?: TronAddresses; - ton: RawAddress; -}; - -export type WalletAddress = { - tron?: TronAddresses; - ton: AddressFormats; -}; - -export type WalletContext = { - address: WalletAddress; - queryClient: QueryClient; - sse: ServerSentEvents; - tonapi: TonAPI; - tronapi: TronAPI; - batteryapi: BatteryAPI; -}; - -export class Wallet { - public identity: WalletIdentity; - public address: WalletAddress; - - public listener: EventSourceListener | null = null; - - public subscriptions: SubscriptionsManager; - public balances: BalancesManager; - public battery: BatteryManager; - - public nfts: NftsManager; - - public activityLoader: ActivityLoader; - public jettonActivityList: JettonActivityList; - public tonActivityList: TonActivityList; - public activityList: ActivityList; - - public tronService: TronService; - - public tonInscriptions: TonInscriptions; - - constructor( - private queryClient: QueryClient, - private tonapi: TonAPI, - private tronapi: TronAPI, - private batteryapi: BatteryAPI, - private vault: Vault, - private sse: ServerSentEvents, - private storage: IStorage, - walletInfo: any, - ) { - this.identity = { - kind: WalletKind.Regular, - network: walletInfo.network, - stateInit: walletInfo.stateInit, - tonProof: walletInfo.tonProof, - }; - - const tonAddresses = Address.parse(walletInfo.address, { - bounceable: walletInfo.bounceable, - }).toAll({ - testOnly: walletInfo.network === WalletNetwork.testnet, - }); - - this.address = { - tron: walletInfo.tronAddress, - ton: tonAddresses, - }; - - // TODO: rewrite - const context: WalletContext = { - queryClient: this.queryClient, - address: this.address, - tronapi: this.tronapi, - tonapi: this.tonapi, - batteryapi: this.batteryapi, - sse: this.sse, - }; - - this.subscriptions = new SubscriptionsManager(context); - - const addresses = { - ton: this.address.ton.raw, - tron: this.address.tron, - }; - - this.activityLoader = new ActivityLoader(addresses, this.tonapi, this.tronapi); - this.jettonActivityList = new JettonActivityList(this.activityLoader, this.storage); - this.tonActivityList = new TonActivityList(this.activityLoader, this.storage); - this.activityList = new ActivityList(this.activityLoader, this.storage); - - this.tonInscriptions = new TonInscriptions(addresses.ton, this.tonapi, this.storage); - - this.balances = new BalancesManager(context); - this.nfts = new NftsManager(context); - this.tronService = new TronService(context); - this.battery = new BatteryManager(context, this.identity, this.storage); - - this.listenTransactions(); - } - - public async getTonPrivateKey(): Promise { - if (false) { - return this.vault.exportWithBiometry(''); - } else { - return this.vault.exportWithPasscode(''); - } - } - - public async getTronPrivateKey(): Promise { - if (false) { - return this.vault.exportWithBiometry(''); - } else { - return this.vault.exportWithPasscode(''); - } - } - - // For migrate - public async setTronAddress(addresses: TronAddresses) { - this.address.tron = addresses; - } - - public async setTonProof(tonProof: string) { - this.identity.tonProof = tonProof; - } - - private listenTransactions() { - this.listener = this.sse.listen('/v2/sse/accounts/transactions', { - accounts: this.address.ton.raw, - }); - this.listener.addEventListener('open', () => { - console.log('[Wallet]: start listen transactions for', this.address.ton.short); - }); - this.listener.addEventListener('error', (err) => { - console.log('[Wallet]: error listen transactions', err); - }); - this.listener.addEventListener('message', () => { - console.log('[Wallet]: message receive'); - this.activityList.reload(); - }); - } - - public destroy() { - this.listener?.close(); - } -} diff --git a/packages/@core-js/src/declarations/ServerSentEvents.d.ts b/packages/@core-js/src/declarations/ServerSentEvents.d.ts deleted file mode 100644 index 89078f805..000000000 --- a/packages/@core-js/src/declarations/ServerSentEvents.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type EventSourceData = Record; - -export type EventSourceCallback = (event: EventSourceData) => void; - -export type EventSourceListener = { - open(): void; - close(): void; - addEventListener(type: string, listener: EventSourceCallback): void; - removeEventListener(type: string, listener: EventSourceCallback): void; - removeAllEventListeners(type?: string): void; - dispatch(type: string, data: EventSourceData): void; -}; - -export type ServerSentEventsOptions = { - baseUrl: () => string; - token: () => string; -}; - -export declare class ServerSentEvents { - constructor(options: ServerSentEventsOptions); - listen(url: string, params?: Record): EventSourceListener; -} - diff --git a/packages/@core-js/src/declarations/Vault.d.ts b/packages/@core-js/src/declarations/Vault.d.ts index af45505fa..22962afcf 100644 --- a/packages/@core-js/src/declarations/Vault.d.ts +++ b/packages/@core-js/src/declarations/Vault.d.ts @@ -1,3 +1,5 @@ +import nacl from 'tweetnacl'; + export type PasscodeShowOptions = { onLogout?: () => void; onPressBiometry?: () => void; @@ -9,14 +11,16 @@ export interface PasscodeController { } export declare class Vault { - public saveWithBiometry(pubkey: string, words: string[]): Promise; - public exportWithBiometry(pubkey: string): Promise; - public removeBiometry(pubkey: string): Promise; - public exportWithPasscode(pubkey: string): Promise; - public removePasscode(pubkey: string): Promise; - public saveWithPasscode( - pubkey: string, - words: string[], + public setupBiometry(passcode: string): Promise; + public removeBiometry(): Promise; + public exportPasscodeWithBiometry(): Promise; + public exportWithPasscode(identifier: string, passcode: string): Promise; + public changePasscode(passcode: string, newPasscode: string): Promise; + public remove(identifier: string, passcode: string): Promise; + public import( + identifier: string, + mnemonic: string, passcode: string, - ): Promise; + ): Promise; + public destroy(): Promise; } diff --git a/packages/@core-js/src/formatters/Address.ts b/packages/@core-js/src/formatters/Address.ts index 47f2fd03a..9ebb4bd62 100644 --- a/packages/@core-js/src/formatters/Address.ts +++ b/packages/@core-js/src/formatters/Address.ts @@ -1,6 +1,6 @@ import TonWeb, { AddressType } from 'tonweb'; -const ContractVersions = ['v3R1', 'v3R2', 'v4R1', 'v4R2'] as const; +const ContractVersions = ['lockup-0.1', 'v3R1', 'v3R2', 'v4R1', 'v4R2'] as const; export type AddressFormats = { friendly: string; @@ -64,6 +64,11 @@ export class Address { return !addr.isUserFriendly || addr.isBounceable; } + static isTestnet(address: string) { + const addr = new TonWeb.Address(address); + return addr.isTestOnly; + } + static compare(adr1?: string, adr2?: string) { if (adr1 === undefined || adr2 === undefined) { return false; @@ -85,24 +90,62 @@ export class Address { static async fromPubkey( pubkey: string | null, - options: FormatOptions = defaultFormatOptions, + isTestnet: boolean, + lockupConfig?: { + workchain: number; + configPubKey?: string; + allowedDestinations?: string; + }, ): Promise { if (!pubkey) return null; const tonweb = new TonWeb(); const addresses = {} as AddressesByVersion; + + const publicKey = Uint8Array.from(Buffer.from(pubkey, 'hex')); for (let contractVersion of ContractVersions) { + if (contractVersion === 'lockup-0.1') { + continue; + } + const wallet = new tonweb.wallet.all[contractVersion](tonweb.provider, { - publicKey: pubkey as any, + publicKey, wc: 0, }); const address = await wallet.getAddress(); - const friendly = address.toString(true, true, options.bounceable, options.testOnly); - const raw = address.toString(false, false, options.bounceable, options.testOnly); + const raw = address.toString(false, false); + const friendly = address.toString(true, true, false, isTestnet); + const short = Address.toShort(friendly); + + addresses[contractVersion] = { + friendly, + raw, + short, + }; + } + + if (lockupConfig) { + const wallet = new tonweb.lockupWallet.all['lockup-0.1'](tonweb.provider, { + publicKey, + wc: lockupConfig.workchain, + config: { + wallet_type: 'lockup-0.1', + config_public_key: lockupConfig.configPubKey, + allowed_destinations: lockupConfig.allowedDestinations, + }, + }); + + const address = await wallet.getAddress(); + const raw = address.toString(false, false); + const friendly = address.toString(true, true, false, isTestnet); const short = Address.toShort(friendly); - addresses[contractVersion] = { friendly, raw, short }; + addresses['lockup-0.1'] = { + friendly, + raw, + short, + }; } return addresses; diff --git a/packages/@core-js/src/index.ts b/packages/@core-js/src/index.ts index fe716c8b4..6365725c2 100644 --- a/packages/@core-js/src/index.ts +++ b/packages/@core-js/src/index.ts @@ -1,4 +1,3 @@ -export { TonAPI, useTonAPI, TonAPIProvider } from './TonAPI'; export * from './formatters/Address'; export * from './formatters/DNS'; @@ -8,17 +7,12 @@ export * from './utils/AmountFormatter'; export * from './utils/network'; export * from './utils/tonapiUtils'; -export * from './useWallet'; -export * from './Tonkeeper'; - export * from './service'; export * from './TronAPI'; -export * from './models/ActivityModel'; - export * from './utils/State'; +export * from './utils/Logger'; export * from './utils/network'; -export * from './declarations/ServerSentEvents.d'; export * from './declarations/Storage.d'; export * from './declarations/Vault.d'; diff --git a/packages/@core-js/src/managers/BalancesManager.ts b/packages/@core-js/src/managers/BalancesManager.ts deleted file mode 100644 index 87fc03d89..000000000 --- a/packages/@core-js/src/managers/BalancesManager.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { WalletContext } from '../Wallet'; - -export class BalancesManager { - public preloaded = undefined; - constructor(private ctx: WalletContext) {} - - public async prefetch() { - this.fetchTron(); - } - public async preload() {} - - public get tonCacheKey() { - return ['balance', this.ctx.address.ton.raw]; - } - - public get tronCacheKey() { - return ['balance', this.ctx.address.tron?.owner]; - } - - public async fetchTron() { - if (!this.ctx.address.tron?.proxy) return; - - const data = await this.ctx.tronapi.balance.getWalletBalances( - this.ctx.address.tron.proxy, - ); - - return data.balances; - } -} diff --git a/packages/@core-js/src/managers/NftsManager.tsx b/packages/@core-js/src/managers/NftsManager.tsx deleted file mode 100644 index b52ccc615..000000000 --- a/packages/@core-js/src/managers/NftsManager.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { CustomNftItem, NftImage } from '../TonAPI/CustomNftItems'; -import { Address } from '../formatters/Address'; -import { WalletContext } from '../Wallet'; -import { NftItem } from '../TonAPI'; - -export class NftsManager { - public persisted = undefined; - constructor(private ctx: WalletContext) {} - - public get cacheKey() { - return ['nfts', this.ctx.address.ton.raw]; - } - - public async preload() {} - - public getCachedByAddress(nftAddress: string, existingNftItem?: NftItem) { - if (existingNftItem) { - return this.makeCustomNftItem(existingNftItem); - } - - const nftItem = this.ctx.queryClient.getQueryData(['nft', nftAddress]); - if (nftItem) { - return nftItem; - } - - return null; - } - - public async fetchByAddress(nftAddress: string) { - const nftItem = await this.ctx.tonapi.nfts.getNftItemByAddress(nftAddress); - - if (nftItem) { - const customNftItem = this.makeCustomNftItem(nftItem); - this.ctx.queryClient.setQueryData(['nft', nftItem.address], customNftItem); - return customNftItem; - } - - throw new Error('No nftItem'); - } - - public makeCustomNftItem(nftItem: NftItem) { - const image = (nftItem.previews ?? []).reduce( - (acc, image) => { - if (image.resolution === '5x5') { - acc.preview = image.url; - } - - if (image.resolution === '100x100') { - acc.small = image.url; - } - - if (image.resolution === '500x500') { - acc.medium = image.url; - } - - if (image.resolution === '1500x1500') { - acc.large = image.url; - } - - return acc; - }, - { - preview: null, - small: null, - medium: null, - large: null, - }, - ); - - const isDomain = !!nftItem.dns; - const isUsername = isTelegramUsername(nftItem.dns); - - const customNftItem: CustomNftItem = { - ...nftItem, - name: nftItem.metadata.name, - isUsername, - isDomain, - image, - }; - - if (customNftItem.metadata) { - customNftItem.marketplaceURL = nftItem.metadata.external_url; - } - - // Custom collection name - if (isDomain && customNftItem.collection) { - customNftItem.collection.name = 'TON DNS'; - } - - // Custom nft name - if (isDomain) { - customNftItem.name = modifyNftName(nftItem.dns)!; - } else if (!customNftItem.name) { - customNftItem.name = Address.toShort(nftItem.address); - } - - return customNftItem; - } - - public fetch() {} - - public prefetch() { - return this.ctx.queryClient.prefetchInfiniteQuery({ - queryFn: () => this.fetch(), - queryKey: this.cacheKey, - }); - } - - public async refetch() { - await this.ctx.queryClient.refetchQueries({ - refetchPage: (_, index) => index === 0, - queryKey: this.cacheKey, - }); - } -} - -export const domainToUsername = (name?: string) => { - return name ? '@' + name.replace('.t.me', '') : ''; -}; - -export const isTelegramUsername = (name?: string) => { - return name?.endsWith('.t.me') || false; -}; - -export const modifyNftName = (name?: string) => { - if (isTelegramUsername(name)) { - return domainToUsername(name); - } - - return name; -}; diff --git a/packages/@core-js/src/managers/SubscriptionsManager.ts b/packages/@core-js/src/managers/SubscriptionsManager.ts deleted file mode 100644 index bb9b4534d..000000000 --- a/packages/@core-js/src/managers/SubscriptionsManager.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { network } from '../utils/network'; -import { WalletContext } from '../Wallet'; - -export class SubscriptionsManager { - constructor(private ctx: WalletContext) {} - - public get cacheKey() { - return ['subscriptions', this.ctx.address.ton.raw]; - } - - public async fetch() { - const { data: subscriptions } = await network.get( - `https://api.tonkeeper.com/v1/subscriptions`, - { - params: { address: this.ctx.address.ton.raw }, - }, - ); - - Object.values(subscriptions.data).map((subscription) => { - this.ctx.queryClient.setQueryData( - ['subscription', subscription.subscriptionAddress], - subscription, - ); - }); - - return subscriptions.data; - } - - public async preload() {} - - public getCachedByAddress(subscriptionAddress: string) { - const subscription = this.ctx.queryClient.getQueryData([ - 'subscription', - subscriptionAddress, - ]); - - if (subscription) { - return subscription; - } - - return null; - } - - public async prefetch() { - return this.ctx.queryClient.fetchQuery({ - queryFn: () => this.fetch(), - queryKey: this.cacheKey, - staleTime: Infinity, - }); - } - - public async refetch() { - return this.ctx.queryClient.refetchQueries({ - queryKey: this.cacheKey, - }); - } -} - -export type Subscriptions = { data: { [key: string]: Subscription } }; - -export type Subscription = { - address: string; - amountNano: string; - chargedAt: number; - fee: string; - id: string; - intervalSec: number; - isActive: boolean; - merchantName: string; - merchantPhoto: string; - productName: string; - returnUrl: string; - status: string; - subscriptionAddress: string; - subscriptionId: number; - userReturnUrl: string; -}; diff --git a/packages/@core-js/src/service/contractService.ts b/packages/@core-js/src/service/contractService.ts index 7a0d480c6..ebdb48760 100644 --- a/packages/@core-js/src/service/contractService.ts +++ b/packages/@core-js/src/service/contractService.ts @@ -6,6 +6,7 @@ import { LockupContractV1AdditionalParams, } from '../legacy'; import { WalletContractV3R1, WalletContractV3R2, WalletContractV4 } from '@ton/ton'; +import nacl from 'tweetnacl'; export enum WalletVersion { v3R1 = 0, @@ -37,6 +38,7 @@ export interface CreateNftTransferBodyParams { /* Address of new owner's address */ newOwnerAddress: AnyAddress; forwardBody?: Cell | string; + /* Query id. Defaults to Tonkeeper signature query id with 32 random bits */ queryId?: number; } @@ -47,6 +49,7 @@ export interface CreateJettonTransferBodyParams { receiverAddress: AnyAddress; jettonAmount: number | bigint; forwardBody?: Cell | string; + /* Query id. Defaults to Tonkeeper signature query id with 32 random bits */ queryId?: number; } @@ -71,6 +74,15 @@ export class ContractService { } } + public static getWalletQueryId() { + const tonkeeperSignature = (0x546de4ef).toString(16); + const value = Buffer.concat([ + Buffer.from(tonkeeperSignature, 'hex'), + nacl.randomBytes(4), + ]); + return BigInt('0x' + value.toString('hex')); + } + static prepareForwardBody(body?: Cell | string) { return typeof body === 'string' ? comment(body) : body; } @@ -78,7 +90,10 @@ export class ContractService { static createNftTransferBody(createNftTransferBodyParams: CreateNftTransferBodyParams) { return beginCell() .storeUint(0x5fcc3d14, 32) - .storeUint(createNftTransferBodyParams.queryId || 0, 64) + .storeUint( + createNftTransferBodyParams.queryId || ContractService.getWalletQueryId(), + 64, + ) .storeAddress(tonAddress(createNftTransferBodyParams.newOwnerAddress)) .storeAddress(tonAddress(createNftTransferBodyParams.excessesAddress)) .storeBit(false) @@ -92,13 +107,15 @@ export class ContractService { ) { return beginCell() .storeUint(0xf8a7ea5, 32) // request_transfer op - .storeUint(createJettonTransferBodyParams.queryId || 0, 64) + .storeUint( + createJettonTransferBodyParams.queryId || ContractService.getWalletQueryId(), + 64, + ) .storeCoins(createJettonTransferBodyParams.jettonAmount) .storeAddress(tonAddress(createJettonTransferBodyParams.receiverAddress)) .storeAddress(tonAddress(createJettonTransferBodyParams.excessesAddress)) .storeBit(false) // null custom_payload .storeCoins(createJettonTransferBodyParams.forwardAmount ?? 1n) - .storeBit(createJettonTransferBodyParams.forwardBody != null) // forward_payload in this slice - false, separate cell - true .storeMaybeRef(this.prepareForwardBody(createJettonTransferBodyParams.forwardBody)) .endCell(); } diff --git a/packages/@core-js/src/useWallet.tsx b/packages/@core-js/src/useWallet.tsx deleted file mode 100644 index 468e81477..000000000 --- a/packages/@core-js/src/useWallet.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { createContext, useState, useContext } from 'react'; - -const initial = { - identity: '333', - name: 'Main', -}; - -const WalletContext = createContext<{ - wallet: typeof initial; - setWallet: (wallet: typeof initial) => void; -}>({ - wallet: initial, - setWallet: () => {}, -}); - -export const WalletProvider = ({ children }: { children: React.ReactNode }) => { - const [wallet, setWallet] = useState(initial); - - return ( - - {children} - - ); -}; - -export const useWallet = () => { - const { wallet } = useContext(WalletContext); - - return wallet; -}; - -export const useSwitchWallet = () => { - const { setWallet } = useContext(WalletContext); - - return (wallet: any) => { - setWallet(wallet); - }; -}; diff --git a/packages/@core-js/src/utils/AmountFormatter/FiatCurrencyConfig.ts b/packages/@core-js/src/utils/AmountFormatter/FiatCurrencyConfig.ts index a1bb3a16d..d18c2684f 100644 --- a/packages/@core-js/src/utils/AmountFormatter/FiatCurrencyConfig.ts +++ b/packages/@core-js/src/utils/AmountFormatter/FiatCurrencyConfig.ts @@ -1,136 +1,136 @@ -export enum FiatCurrencies { - Ton = 'ton', - Usd = 'usd', - Eur = 'eur', - Rub = 'rub', - Aed = 'aed', - Gbp = 'gbp', - Chf = 'chf', - Cny = 'cny', - Krw = 'krw', - Idr = 'idr', - Inr = 'inr', - Jpy = 'jpy', - Uah = 'uah', - Kzt = 'kzt', - Uzs = 'uzs', - Irr = 'irr', - Brl = 'brl', - Cad = 'cad', - Byn = 'byn', - Sgd = 'sgd', - Thb = 'thb', - Vnd = 'vnd', - Ngn = 'ngn', - Bdt = 'bdt', - Try = 'try', - Ils = 'ils', - Dkk = 'dkk', - Gel = 'gel', +export enum WalletCurrency { + TON = 'ton', + USD = 'usd', + EUR = 'eur', + RUB = 'rub', + AED = 'aed', + GBP = 'gbp', + CHF = 'chf', + CNY = 'cny', + KRW = 'krw', + IDR = 'idr', + INR = 'inr', + JPY = 'jpy', + UAH = 'uah', + KZT = 'kzt', + UZS = 'uzs', + IRR = 'irr', + BRL = 'brl', + CAD = 'cad', + BYN = 'byn', + SGD = 'sgd', + THB = 'thb', + VND = 'vnd', + NGN = 'ngn', + BDT = 'bdt', + TRY = 'try', + ILS = 'ils', + DKK = 'dkk', + GEL = 'gel', } export const FiatCurrencySymbolsConfig = { - [FiatCurrencies.Ton]: { + [WalletCurrency.TON]: { symbol: 'TON', side: 'end', }, - [FiatCurrencies.Usd]: { + [WalletCurrency.USD]: { symbol: '$', side: 'start', }, - [FiatCurrencies.Eur]: { + [WalletCurrency.EUR]: { symbol: '€', side: 'start', }, - [FiatCurrencies.Rub]: { + [WalletCurrency.RUB]: { symbol: '₽', side: 'end', }, - [FiatCurrencies.Idr]: { + [WalletCurrency.IDR]: { symbol: 'Rp', side: 'end', }, - [FiatCurrencies.Uah]: { + [WalletCurrency.UAH]: { symbol: '₴', side: 'end', }, - [FiatCurrencies.Uzs]: { + [WalletCurrency.UZS]: { symbol: 'Sum', side: 'end', }, - [FiatCurrencies.Inr]: { + [WalletCurrency.INR]: { symbol: '₹', side: 'start', }, - [FiatCurrencies.Gbp]: { + [WalletCurrency.GBP]: { symbol: '£', side: 'start', }, - [FiatCurrencies.Aed]: { + [WalletCurrency.AED]: { symbol: 'DH', side: 'end', }, - [FiatCurrencies.Cny]: { + [WalletCurrency.CNY]: { symbol: '¥', side: 'start', }, - /* [FiatCurrencies.Irr]: { - symbol: '₸', - side: 'end', - },*/ - [FiatCurrencies.Byn]: { + // [WalletCurrency.IRR]: { + // symbol: '₸', + // side: 'end', + // }, + [WalletCurrency.BYN]: { symbol: 'Br', side: 'end', }, - [FiatCurrencies.Brl]: { + [WalletCurrency.BRL]: { symbol: 'R$', side: 'start', }, - [FiatCurrencies.Try]: { + [WalletCurrency.TRY]: { symbol: '₺', side: 'end', }, - [FiatCurrencies.Kzt]: { + [WalletCurrency.KZT]: { symbol: '₸', side: 'end', }, - [FiatCurrencies.Ngn]: { + [WalletCurrency.NGN]: { symbol: '₦', side: 'end', }, - [FiatCurrencies.Krw]: { + [WalletCurrency.KRW]: { symbol: '₩', side: 'start', }, - [FiatCurrencies.Thb]: { + [WalletCurrency.THB]: { symbol: '฿', side: 'end', }, - [FiatCurrencies.Bdt]: { + [WalletCurrency.BDT]: { symbol: '৳', side: 'end', }, - [FiatCurrencies.Chf]: { + [WalletCurrency.CHF]: { symbol: '₣', side: 'start', }, - [FiatCurrencies.Jpy]: { + [WalletCurrency.JPY]: { symbol: '¥', side: 'start', }, - [FiatCurrencies.Cad]: { + [WalletCurrency.CAD]: { symbol: '$', side: 'end', }, - [FiatCurrencies.Ils]: { + [WalletCurrency.ILS]: { symbol: '₪', side: 'end', }, - [FiatCurrencies.Gel]: { + [WalletCurrency.GEL]: { symbol: '₾', side: 'end', }, - [FiatCurrencies.Vnd]: { + [WalletCurrency.VND]: { symbol: '₫', side: 'end', }, diff --git a/packages/@core-js/src/utils/jettons.ts b/packages/@core-js/src/utils/jettons.ts new file mode 100644 index 000000000..437f172bd --- /dev/null +++ b/packages/@core-js/src/utils/jettons.ts @@ -0,0 +1,27 @@ +import { JettonBalance } from '../TonAPI'; +import BigNumber from 'bignumber.js'; +import { AmountFormatter } from './AmountFormatter'; + +export function sortByPrice(a: JettonBalance, b: JettonBalance) { + if (!a.price?.prices) { + return !b.price?.prices ? 0 : 1; + } + + if (!b.price?.prices) { + return -1; + } + + const aTotal = BigNumber(a.price?.prices!.TON).multipliedBy( + AmountFormatter.fromNanoStatic(a.balance, a.jetton.decimals), + ); + const bTotal = BigNumber(b.price?.prices!.TON).multipliedBy( + AmountFormatter.fromNanoStatic(b.balance, b.jetton.decimals), + ); + if (bTotal.gt(aTotal)) { + return 1; + } else if (aTotal.gt(bTotal)) { + return -1; + } else { + return 0; + } +} diff --git a/packages/@core-js/tsconfig.json b/packages/@core-js/tsconfig.json index ec6ffdbd3..ce0d42de1 100644 --- a/packages/@core-js/tsconfig.json +++ b/packages/@core-js/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["./src"], + "include": ["./src", "../mobile/src/wallet/Tonkeeper.ts"], } \ No newline at end of file diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 48ceaafac..ef0441fb9 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -92,7 +92,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 433 - versionName "3.6" + versionName "4.0.0" missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'store', 'play' } diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index 5ffa399cd..074ae426c 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ + @@ -64,6 +65,7 @@ + diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js index 8e992fd31..dc0895062 100644 --- a/packages/mobile/babel.config.js +++ b/packages/mobile/babel.config.js @@ -1,5 +1,3 @@ -const isProd = process.env.NODE_ENV === 'production'; - const plugins = [ ['@babel/plugin-transform-flow-strip-types'], ['@babel/plugin-transform-private-methods', { loose: true }], @@ -25,19 +23,18 @@ const plugins = [ $shared: './src/shared', $navigation: './src/navigation', $translation: './src/translation', - $services: './src/services', + $wallet: './src/wallet', + $logger: './src/logger', $blockchain: './src/blockchain', $database: './src/database', $tonconnect: './src/tonconnect', + $config: './src/config', + $components: './src/components', }, }, ], ]; -// if (isProd) { -// plugins.push('transform-remove-console'); -// } - plugins.push([ 'react-native-reanimated/plugin', { @@ -47,5 +44,10 @@ plugins.push([ module.exports = { presets: ['module:metro-react-native-babel-preset'], + env: { + production: { + plugins: ['transform-remove-console'], //removing consoles.log from app during release (production) versions + }, + }, plugins, }; diff --git a/packages/mobile/index.js b/packages/mobile/index.js index 69f65ebd5..6e1dd78cf 100644 --- a/packages/mobile/index.js +++ b/packages/mobile/index.js @@ -16,12 +16,12 @@ import { import { App } from '$core/App'; import { name as appName } from './app.json'; import { debugLog } from './src/utils/debugLog'; -import { mainActions } from './src/store/main'; -import { store, useNotificationsStore } from './src/store'; +import { useNotificationsStore } from './src/store'; import { getAttachScreenFromStorage } from '$navigation/AttachScreen'; import crashlytics from '@react-native-firebase/crashlytics'; import messaging from '@react-native-firebase/messaging'; import { withIAPContext } from 'react-native-iap'; +import { startApp } from './src/index'; LogBox.ignoreLogs([ 'Non-serializable values were found in the navigation state', @@ -42,10 +42,13 @@ async function handleDappMessage(remoteMessage) { } await useNotificationsStore.persist.rehydrate(); - useNotificationsStore.getState().actions.addNotification({ - ...remoteMessage.data, - received_at: parseInt(remoteMessage.data.sent_at) || Date.now(), - }); + useNotificationsStore.getState().actions.addNotification( + { + ...remoteMessage.data, + received_at: parseInt(remoteMessage.data.sent_at) || Date.now(), + }, + remoteMessage.data.account, + ); await useNotificationsStore.persist.rehydrate(); return; } @@ -62,7 +65,6 @@ setNativeExceptionHandler((exceptionString) => { debugLog('NativeError', exceptionString); }); -store.dispatch(mainActions.init()); -// tonkeeper.init(); +startApp(); AppRegistry.registerComponent(appName, () => withIAPContext(gestureHandlerRootHOC(App))); diff --git a/packages/mobile/ios/Podfile b/packages/mobile/ios/Podfile index 9a35b48c3..11c2ee924 100644 --- a/packages/mobile/ios/Podfile +++ b/packages/mobile/ios/Podfile @@ -5,7 +5,7 @@ require File.join(File.dirname(`node --print "require.resolve('@react-native-com require 'json' podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} -platform :ios, '13.0' +platform :ios, '13.4' install! 'cocoapods', :deterministic_uuids => false diff --git a/packages/mobile/ios/ton_keeper.xcodeproj/project.pbxproj b/packages/mobile/ios/ton_keeper.xcodeproj/project.pbxproj index 8816338ea..3742e1b05 100644 --- a/packages/mobile/ios/ton_keeper.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/ton_keeper.xcodeproj/project.pbxproj @@ -1232,7 +1232,7 @@ "$(inherited)", ); INFOPLIST_FILE = ton_keeperTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1258,7 +1258,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; COPY_PHASE_STRIP = NO; INFOPLIST_FILE = ton_keeperTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1289,12 +1289,12 @@ DEVELOPMENT_TEAM = CT523DK2KC; ENABLE_BITCODE = NO; INFOPLIST_FILE = ton_keeper/SupportingFiles/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.6; + MARKETING_VERSION = 4.0.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1323,12 +1323,12 @@ DEFINES_MODULE = YES; DEVELOPMENT_TEAM = CT523DK2KC; INFOPLIST_FILE = ton_keeper/SupportingFiles/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.6; + MARKETING_VERSION = 4.0.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1395,7 +1395,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -1465,7 +1465,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 3aee25885..98662d40d 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -27,6 +27,7 @@ "dependencies": { "@alexzunik/rn-native-portals-reborn": "^1.0.5", "@amplitude/analytics-browser": "^1.6.7", + "@aptabase/react-native": "^0.3.9", "@bogoslavskiy/react-native-steezy": "^1.0.4", "@craftzdog/react-native-buffer": "^6.0.5", "@expo/react-native-action-sheet": "^4.0.1", @@ -49,6 +50,7 @@ "@tonkeeper/router": "*", "@tonkeeper/shared": "*", "@tonkeeper/uikit": "*", + "@types/uuid": "^9.0.8", "@vkontakte/vk-qr": "^2.0.13", "axios": "^0.27.2", "bignumber.js": "^9.0.1", @@ -61,7 +63,7 @@ "dotenv": "^16.0.3", "expo": "~49.0.13", "expo-blur": "~12.6.0", - "expo-local-authentication": "~13.6.0", + "expo-local-authentication": "0.0.1-canary-20240228-7cee619", "expo-modules-core": "~1.5.11", "expo-secure-store": "~12.5.0", "expo-splash-screen": "~0.22.0", @@ -72,7 +74,6 @@ "isomorphic-webcrypto": "^2.3.8", "js-sha512": "^0.8.0", "jsbi": "^3.2.1", - "jsonwebtoken": "^8.5.1", "linkify-it": "^3.0.2", "lodash": "^4.17.21", "lottie-ios": "4.3.0", @@ -105,6 +106,7 @@ "react-native-keyboard-controller": "^1.5.8", "react-native-linear-gradient": "^2.6.2", "react-native-localize": "^2.2.4", + "react-native-logs": "^5.1.0", "react-native-minimizer": "1.3.0", "react-native-pager-view": "https://github.com/bogoslavskiy/react-native-pager-view#78c0bb573fce185f6f51bae6c1c566a1ec6294eb", "react-native-permissions": "3.6.1", @@ -126,7 +128,7 @@ "react-native-tweetnacl": "^1.0.5", "react-native-url-polyfill": "^2.0.0", "react-native-video": "^5.2.0", - "react-native-webview": "^11.14.0", + "react-native-webview": "^13.7.1", "react-query": "^3.39.3", "react-redux": "^7.2.4", "redux-saga": "^1.1.3", @@ -154,6 +156,7 @@ "@types/styled-components": "^5.1.12", "@types/styled-components-react-native": "^5.1.1", "babel-jest": "^29.2.1", + "babel-plugin-transform-remove-console": "^6.9.4", "eslint": "^8.19.0", "expo-yarn-workspaces": "^2.1.0", "jest": "^29.2.1", diff --git a/packages/mobile/src/api/index.ts b/packages/mobile/src/api/index.ts deleted file mode 100644 index 4387dfc67..000000000 --- a/packages/mobile/src/api/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import axios from 'axios'; -import { i18n } from '$translation'; -import { Platform } from 'react-native'; -import DeviceInfo from 'react-native-device-info'; - -import { t } from '@tonkeeper/shared/i18n'; -import { getIsTestnet } from '$database'; -import { getServerConfigSafe } from '$shared/constants/serverConfig'; - -export const Api = axios.create({ - withCredentials: true, - headers: { - 'Content-Type': 'application/json', - }, -}); - -Api.interceptors.request.use( - async (config) => { - const isTestnet = await getIsTestnet(); - let tonkeeperEndpoint = getServerConfigSafe('tonkeeperEndpoint'); - - if (tonkeeperEndpoint === 'none') { - tonkeeperEndpoint = 'https://api.tonkeeper.com'; // fallback - } - - return { - ...config, - baseURL: `${tonkeeperEndpoint}/v1`, - headers: { - ...config.headers, - lang: i18n.locale, - platform: Platform.OS, - build: DeviceInfo.getReadableVersion(), - chainName: isTestnet ? 'testnet' : 'mainnet', - }, - }; - }, - (error) => { - console.warn('error', error); - return Promise.reject(error); - }, -); - -Api.interceptors.response.use( - (response) => { - if (['document', 'text'].indexOf(response.config.responseType as string) > -1) { - return response.data; - } - - if (response.data && 'success' in response.data) { - return response.data.success - ? Promise.resolve(response.data.data) - : Promise.reject({ - ...response.data.data, - message: response.data.data.error_reason, - }); - } else { - return Promise.reject(response.data); - } - }, - (error) => { - if (error.message === 'Network Error') { - return Promise.reject({ - message: t('error_network'), - isNetworkError: true, - }); - } - - if (error.response && error.response.data && 'success' in error.response.data) { - return Promise.reject({ - ...error.response.data.data, - message: error.response.data.data.error_reason, - }); - } else { - return Promise.reject(error.response ? error.response.data : error); - } - }, -); diff --git a/packages/mobile/src/assets/icons/png/ic-fire-badge-32@4x.png b/packages/mobile/src/assets/icons/png/ic-fire-badge-32@4x.png index 21fd50c79..1a4f75eeb 100644 Binary files a/packages/mobile/src/assets/icons/png/ic-fire-badge-32@4x.png and b/packages/mobile/src/assets/icons/png/ic-fire-badge-32@4x.png differ diff --git a/packages/mobile/src/blockchain/index.ts b/packages/mobile/src/blockchain/index.ts index 739deac52..38ed810b1 100644 --- a/packages/mobile/src/blockchain/index.ts +++ b/packages/mobile/src/blockchain/index.ts @@ -1,2 +1,3 @@ export * from './vault'; export * from './wallet'; +export * from './legacy'; diff --git a/packages/mobile/src/blockchain/legacy.ts b/packages/mobile/src/blockchain/legacy.ts new file mode 100644 index 000000000..af6c63295 --- /dev/null +++ b/packages/mobile/src/blockchain/legacy.ts @@ -0,0 +1,19 @@ +import { Wallet } from '$wallet/Wallet'; +import { Vault } from './vault'; +import { TonWallet, Wallet as LegacyWallet } from './wallet'; + +export const createLegacyWallet = (wallet: Wallet) => { + const vault = Vault.fromJSON({ + name: wallet.identifier, + tonPubkey: wallet.pubkey, + version: wallet.config.version, + workchain: wallet.config.workchain, + configPubKey: wallet.config.configPubKey, + allowedDestinations: wallet.config.allowedDestinations, + }); + const ton = TonWallet.fromJSON(null, vault); + + const legacyWallet = new LegacyWallet(wallet.identifier, vault, ton); + + return legacyWallet; +}; diff --git a/packages/mobile/src/blockchain/vault.ts b/packages/mobile/src/blockchain/vault.ts index 18e13878d..7d160d0df 100644 --- a/packages/mobile/src/blockchain/vault.ts +++ b/packages/mobile/src/blockchain/vault.ts @@ -1,35 +1,12 @@ -import EncryptedStorage from 'react-native-encrypted-storage'; import { Buffer } from 'buffer'; -import { generateSecureRandom } from 'react-native-securerandom'; -import scrypt from 'react-native-scrypt'; -import BN from 'bn.js'; -import { getServerConfig } from '$shared/constants'; -import { MainDB } from '$database'; -import { debugLog } from '$utils/debugLog'; -import * as SecureStore from 'expo-secure-store'; -import { t } from '@tonkeeper/shared/i18n'; import { Ton } from '$libs/Ton'; import { wordlist } from '$libs/Ton/mnemonic/wordlist'; -import { Tonapi } from '$libs/Tonapi'; -import { tk } from '@tonkeeper/shared/tonkeeper'; - -const { nacl } = require('react-native-tweetnacl'); +import { config } from '$config'; const TonWeb = require('tonweb'); const DEFAULT_VERSION = 'v4R2'; -/* -Usage: - -let vault: UnlockedVault = await Vault.generate("vault-0"); -let encryptedVault: EncryptedVault = vault.encrypt("my password"); -let vault: Vault = encryptedVault.lock(); // stores the keychain item and removes secrets from memory. -let vault: = vault.unlock(); // triggers faceid to load the secret from the store. -let backup = vault.mnemonic; // returns string -let tonAddress = await vault.getTonAddress(); -*/ - // Non-secret metadata of the vault necessary to generate addresses and check balances. export type VaultJSON = { // Name for the system keychain item to retrieve secret data @@ -64,7 +41,6 @@ type VaultInfo = { // but no secret data. export class Vault { protected info: VaultInfo; - protected keychainService = 'TKProtected'; // Private constructor. // Use `Vault.generate` to create a new vault and `Vault.fromJSON` to load one from disk. @@ -81,15 +57,15 @@ export class Vault { // whether the password is required or not. We use password only for on-device storage, // and keep *this* password empty. const phrase = (await Ton.mnemonic.generateMnemonic(24)).join(' '); - return await Vault.restore(name, phrase, DEFAULT_VERSION); + return await Vault.restore(name, phrase, [DEFAULT_VERSION]); } // Restores the wallet from the mnemonic phrase. static async restore( name: string, phrase: string, - version?: string, - config?: any, + versions: string[], + lockupConfig?: any, ): Promise { //if (!bip39.validateMnemonic(phrase, bip39.DEFAULT_WORDLIST)) { // TON uses custom mnemonic format with BIP39 words, but bruteforced so that first byte indicates @@ -101,84 +77,29 @@ export class Vault { const tonKeyPair = await Ton.mnemonic.mnemonicToKeyPair(phrase.split(' ')); const tonPubkey = tonKeyPair.publicKey; - // await tk.generateTronAddress(tonKeyPair.secretKey); - await tk.obtainProofToken(tonKeyPair); - const info: VaultInfo = { name: name, tonPubkey, workchain: 0, }; - if (config) { - version = config.wallet_type; - info.workchain = config.workchain; - info.configPubKey = config.config_pubkey; - info.allowedDestinations = config.allowed_destinations; + if (lockupConfig) { + versions = [lockupConfig.wallet_type]; + info.workchain = lockupConfig.workchain; + info.configPubKey = lockupConfig.config_pubkey; + info.allowedDestinations = lockupConfig.allowed_destinations; console.log('lockup wallet detected'); - } else { - if (!version) { - try { - const provider = new TonWeb.HttpProvider(getServerConfig('tonEndpoint'), { - apiKey: getServerConfig('tonEndpointAPIKey'), - }); - const ton = new TonWeb(provider); - - let hasBalance: { balance: BN; version: string }[] = []; - for (let WalletClass of ton.wallet.list) { - const wallet = new WalletClass(ton.provider, { - publicKey: info.tonPubkey, - wc: 0, - }); - const walletAddress = (await wallet.getAddress()).toString(true, true, true); - const walletInfo = await Tonapi.getWalletInfo(walletAddress); - const walletBalance = new BN(walletInfo.balance); - if (walletBalance.gt(new BN(0))) { - hasBalance.push({ balance: walletBalance, version: wallet.getName() }); - } - } - - if (hasBalance.length > 0) { - hasBalance.sort((a, b) => { - return a.balance.cmp(b.balance); - }); - version = hasBalance[hasBalance.length - 1].version; - } else { - version = DEFAULT_VERSION; - } - console.log('version detected', version); - } catch (e) { - throw new Error('Failed to get addresses balances'); - } - } else { - console.log('version passed', version); - } } - info.version = version; - - return new UnlockedVault(info, phrase); - } + info.version = versions[0]; - // Returns true if the device has a passcode/biometric protection. - // If it does not, app asks user to encrypt the wallet with a password. - static async isDeviceProtected(): Promise { - return await EncryptedStorage.isDeviceProtected(); - } - - get keychainItemName(): string { - return this.info.name; + return new UnlockedVault(info, phrase, versions); } get name(): string { return this.info.name; } - // Returns true if the vault is currently locked. - get locked(): boolean { - return true; - } - // Instantiates a vault in a locked state from the non-secret metadata. static fromJSON(json: VaultJSON): Vault { const tonPubkey = Uint8Array.from(Buffer.from(json.tonPubkey, 'hex')); @@ -192,10 +113,6 @@ export class Vault { }); } - setVersion(version: string) { - this.info.version = version; - } - getVersion() { return this.info.version; } @@ -213,10 +130,6 @@ export class Vault { return this.info.workchain ?? 0; } - isMasterChain() { - return this.info.workchain === -1; - } - getLockupConfig() { return { wallet_type: this.info.version, @@ -238,70 +151,6 @@ export class Vault { }; } - async clean() { - const isNewFlow = await MainDB.isNewSecurityFlow(); - if (isNewFlow) { - await SecureStore.deleteItemAsync(this.keychainItemName); - } else { - await EncryptedStorage.removeItem(this.keychainItemName); - } - } - - async cleanBiometry() { - await SecureStore.deleteItemAsync('biometry_' + this.keychainItemName, { - keychainService: this.keychainService, - }); - } - - // Attemps to unlock the vault's secret data and returns the new vault state. - async unlock(): Promise { - const isNewFlow = await MainDB.isNewSecurityFlow(); - - let jsonstr: any; - if (isNewFlow) { - try { - const storedKeychainService = await MainDB.getKeychainService(); - if (storedKeychainService) { - this.keychainService = storedKeychainService; - } - - jsonstr = await SecureStore.getItemAsync('biometry_' + this.keychainItemName, { - keychainService: this.keychainService, - }); - } catch { - throw new Error(t('access_confirmation_update_biometry')); - } - } else { - jsonstr = await EncryptedStorage.getItem(this.keychainItemName); - } - - if (jsonstr == null) { - debugLog( - 'EncryptedStorage.getItem is empty.', - 'Item name: ', - this.keychainItemName, - ); - throw new Error('Failed to unlock the vault'); - } - - const state: EncryptedSecret | DecryptedSecret = JSON.parse(jsonstr); - if (state.kind === 'decrypted') { - return new UnlockedVault(this.info, state.mnemonic); - } else { - return new EncryptedVault(this.info, state); - } - } - - async getEncrypted(): Promise { - const jsonstr = await SecureStore.getItemAsync(this.keychainItemName); - if (jsonstr == null) { - throw new Error('Failed to unlock the vault'); - } - - const state: EncryptedSecret = JSON.parse(jsonstr); - return new EncryptedVault(this.info, state); - } - // TON public key get tonPublicKey(): Uint8Array { return this.info.tonPubkey; @@ -310,8 +159,8 @@ export class Vault { // TON wallet instance. get tonWallet(): any { const tonweb = new TonWeb( - new TonWeb.HttpProvider(getServerConfig('tonEndpoint'), { - apiKey: getServerConfig('tonEndpointAPIKey'), + new TonWeb.HttpProvider(config.get('tonEndpoint'), { + apiKey: config.get('tonEndpointAPIKey'), }), ); @@ -335,8 +184,8 @@ export class Vault { tonWalletByVersion(version: string) { const tonweb = new TonWeb( - new TonWeb.HttpProvider(getServerConfig('tonEndpoint'), { - apiKey: getServerConfig('tonEndpointAPIKey'), + new TonWeb.HttpProvider(config.get('tonEndpoint'), { + apiKey: config.get('tonEndpointAPIKey'), }), ); @@ -372,139 +221,24 @@ export class Vault { } } -export class EncryptedVault extends Vault { - private state: EncryptedSecret; - - // Private constructor. - // Use `Vault.generate` to create a new vault and `Vault.fromJSON` to load one from disk. - constructor(info: VaultInfo, state: EncryptedSecret) { - super(info); - this.state = state; - } - - // Returns true if the vault is currently locked. - get locked(): boolean { - return false; - } - - // Saves the secret data to the system keychain and returns locked vault instance w/o secret data. - async lock(): Promise { - let jsonstr = JSON.stringify(this.state); - await SecureStore.setItemAsync(this.keychainItemName, jsonstr); - - return new Vault(this.info); - } - // Returns true if the vault is unlocked, but encrypted. - get needsDecrypt(): boolean { - return true; - } - - // Attempts to decrypt the vault and returns `true` if succeeded. - async decrypt(password: string): Promise { - if (this.state.kind === 'encrypted-scrypt-tweetnacl') { - const salt = Buffer.from(this.state.salt, 'hex'); - const { N, r, p } = this.state; - const enckey = await scrypt( - Buffer.from(password, 'utf8'), - salt, - N, - r, - p, - 32, - 'buffer', - ); - const nonce = salt.slice(0, 24); - const ct = Buffer.from(this.state.ct, 'hex'); - const pt: Uint8Array = nacl.secretbox.open( - ct, - Uint8Array.from(nonce), - Uint8Array.from(enckey), - ); - const phrase = Utf8ArrayToString(pt); - return new UnlockedVault(this.info, phrase); - } else { - throw new Error('Unsupported encryption format ' + this.state.kind); - } - } -} - export class UnlockedVault extends Vault { // Mnemonic phrase string that represents the root for all the keys. public mnemonic: string; + public versions?: string[]; // Private constructor. // Use `Vault.generate` to create a new vault and `Vault.fromJSON` to load one from disk. - constructor(info: VaultInfo, mnemonic: string) { + constructor(info: VaultInfo, mnemonic: string, versions?: string[]) { super(info); this.mnemonic = mnemonic; + this.versions = versions; } - // Returns true if the vault is currently locked. - get locked(): boolean { - return false; - } - - async updateKeychainService() { - this.keychainService = `TKProtected${Math.random()}`; - await MainDB.setKeychainService(this.keychainService); - } - - // Saves the secret data to the system keychain and returns locked vault instance w/o secret data. - async lock(): Promise { - await this.saveBiometry(); - return new Vault(this.info); - } - - async saveBiometry() { - let jsonstr = JSON.stringify({ - kind: 'decrypted', - mnemonic: this.mnemonic, - }); - await this.updateKeychainService(); - await SecureStore.setItemAsync('biometry_' + this.keychainItemName, jsonstr, { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, - keychainService: this.keychainService, - requireAuthentication: true, - }); - } - - // Returns true if the vault is unlocked, but encrypted. - get needsDecrypt(): boolean { - return false; - } - - // Encrypts the vault - async encrypt(password: string): Promise { - // default parameters - const N = 16384; // 16K*128*8 = 16 Mb of memory - const r = 8; - const p = 1; - - const salt = Buffer.from(await generateSecureRandom(32)); - const enckey = await scrypt( - Buffer.from(password, 'utf8'), - salt, - N, - r, - p, - 32, - 'buffer', - ); - const nonce = salt.slice(0, 24); - const ct: Uint8Array = nacl.secretbox( - Uint8Array.from(Buffer.from(this.mnemonic, 'utf8')), - Uint8Array.from(nonce), - Uint8Array.from(enckey), - ); - - return new EncryptedVault(this.info, { - kind: 'encrypted-scrypt-tweetnacl', - N: N, // scrypt "cost" parameter - r: r, // scrypt "block size" parameter - p: p, // scrypt "parallelization" parameter - salt: salt.toString('hex'), // hex-encoded nonce/salt - ct: Buffer.from(ct).toString('hex'), // hex-encoded ciphertext - }); + public setConfig(lockupConfig: any) { + this.info.version = lockupConfig.wallet_type; + this.info.workchain = lockupConfig.workchain; + this.info.configPubKey = lockupConfig.config_pubkey; + this.info.allowedDestinations = lockupConfig.allowed_destinations; } // Ton private key. Throws an error if the vault is not unlocked. @@ -517,67 +251,4 @@ export class UnlockedVault extends Vault { async getKeyPair(): Promise { return await Ton.mnemonic.mnemonicToKeyPair(this.mnemonic.split(' ')); } - - public setConfig(config: any) { - this.info.version = config.wallet_type; - this.info.workchain = config.workchain; - this.info.configPubKey = config.config_pubkey; - this.info.allowedDestinations = config.allowed_destinations; - } -} - -// encrypted vault is used when the user sets an in-app password -// for additional security, e.g. when the device passcode is not set. -export type EncryptedSecret = { - kind: 'encrypted-scrypt-tweetnacl'; - N: number; // scrypt "cost" parameter - r: number; // scrypt "block size" parameter - p: number; // scrypt "parallelization" parameter - salt: string; // hex-encoded nonce/salt - ct: string; // hex-encoded ciphertext -}; - -// unencrypted vault is stored within a secure keystore -// under protection of the device passcode/touchid/faceid. -export type DecryptedSecret = { - kind: 'decrypted'; - mnemonic: string; // 12-word space-separated phrase per BIP39 -}; - -function Utf8ArrayToString(array: Uint8Array): string { - let out = ''; - let len = array.length; - let i = 0; - let c: any = null; - while (i < len) { - c = array[i++]; - switch (c >> 4) { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - // 0xxxxxxx - out += String.fromCharCode(c); - break; - case 12: - case 13: - // 110x xxxx 10xx xxxx - let char = array[i++]; - out += String.fromCharCode(((c & 0x1f) << 6) | (char & 0x3f)); - break; - case 14: - // 1110 xxxx 10xx xxxx 10xx xxxx - let a = array[i++]; - let b = array[i++]; - out += String.fromCharCode( - ((c & 0x0f) << 12) | ((a & 0x3f) << 6) | ((b & 0x3f) << 0), - ); - break; - } - } - return out; } diff --git a/packages/mobile/src/blockchain/wallet.ts b/packages/mobile/src/blockchain/wallet.ts index deceaeaaf..b08c929fb 100644 --- a/packages/mobile/src/blockchain/wallet.ts +++ b/packages/mobile/src/blockchain/wallet.ts @@ -1,9 +1,7 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import BigNumber from 'bignumber.js'; import { getUnixTime } from 'date-fns'; import { store } from '$store'; -import { getServerConfig } from '$shared/constants'; import { UnlockedVault, Vault } from './vault'; import { Address as AddressFormatter, @@ -15,11 +13,9 @@ import { TransactionService, } from '@tonkeeper/core'; import { debugLog } from '$utils/debugLog'; -import { getChainName, getWalletName } from '$shared/dynamicConfig'; import { t } from '@tonkeeper/shared/i18n'; import { Ton } from '$libs/Ton'; -import { Tonapi } from '$libs/Tonapi'; import { Address as TAddress } from '$store/wallet/interface'; import { Account, @@ -28,15 +24,18 @@ import { Configuration, } from '@tonkeeper/core/src/legacy'; -import { tk, tonapi } from '@tonkeeper/shared/tonkeeper'; -import { Address, Cell, internal, toNano } from '@ton/core'; -import { config } from '@tonkeeper/shared/config'; +import { tk } from '$wallet'; +import { Address, Cell, internal } from '@ton/core'; import { emulateWithBattery, sendBocWithBattery, } from '@tonkeeper/shared/utils/blockchain'; -import { OperationEnum, TypeEnum } from '@tonkeeper/core/src/TonAPI'; +import { OperationEnum, TonAPI, TypeEnum } from '@tonkeeper/core/src/TonAPI'; import { setBalanceForEmulation } from '@tonkeeper/shared/utils/wallet'; +import { WalletNetwork } from '$wallet/WalletTypes'; +import { createTonApiInstance } from '$wallet/utils'; +import { config } from '$config'; +import { toNano } from '$utils'; const TonWeb = require('tonweb'); @@ -73,21 +72,28 @@ export class Wallet { readonly ton: TonWallet; + tonapi: TonAPI; + constructor(name: string, vault: Vault, ton: TonWallet = new TonWallet(vault)) { this.name = name; this.vault = vault; this.ton = ton; this.getReadableAddress(); + + this.tonapi = createTonApiInstance( + tk.wallet.config.network === WalletNetwork.testnet, + ); } public async getReadableAddress() { if (this.vault) { const rawAddress = await this.vault.getRawTonAddress(); + const tkWallet = tk.wallets.get(this.name)!; const friendlyAddress = await this.vault.getTonAddress( - !(getChainName() === 'mainnet'), + tkWallet.config.network === WalletNetwork.testnet, ); - const version = this.vault.getVersion(); + const version = this.vault.getVersion()!; this.address = { rawAddress: rawAddress.toString(false), @@ -97,63 +103,45 @@ export class Wallet { } } - // Loads wallet from the disk storage - static async load(name: string = getWalletName()): Promise { - const data = await AsyncStorage.getItem(`${name}_wallet`); - - if (!data) { - return null; - } - - try { - const json = JSON.parse(data); - const vault = Vault.fromJSON(json.vault); - const ton = TonWallet.fromJSON(json.ton, vault); - - return new Wallet(name, vault, ton); - } catch (e) {} - - return null; - } - // Saves the wallet on disk - async save(): Promise { - await AsyncStorage.setItem( - `${this.name}_wallet`, - JSON.stringify({ - vault: this.vault.toJSON(), - ton: this.ton.toJSON(), - }), - ); - } - async clean() { - await AsyncStorage.removeItem(`${this.name}_wallet`); + await tk.removeWallet(this.vault.name); } } export class TonWallet { private tonweb: any; - private vault: Vault; + public vault: Vault; private blockchainApi: BlockchainApi; private accountsApi: AccountsApi; + private tonapi: TonAPI; + constructor(vault: Vault, provider: any = null) { if (!provider) { - provider = new TonWeb.HttpProvider(getServerConfig('tonEndpoint'), { - apiKey: getServerConfig('tonEndpointAPIKey'), + provider = new TonWeb.HttpProvider(config.get('tonEndpoint'), { + apiKey: config.get('tonEndpointAPIKey'), }); } this.vault = vault; this.tonweb = new TonWeb(provider); const tonApiConfiguration = new Configuration({ - basePath: getServerConfig('tonapiV2Endpoint'), + basePath: config.get( + 'tonapiV2Endpoint', + tk.wallet.config.network === WalletNetwork.testnet, + ), headers: { - Authorization: `Bearer ${getServerConfig('tonApiV2Key')}`, + Authorization: `Bearer ${config.get( + 'tonApiV2Key', + tk.wallet.config.network === WalletNetwork.testnet, + )}`, }, }); this.blockchainApi = new BlockchainApi(tonApiConfiguration); this.accountsApi = new AccountsApi(tonApiConfiguration); + this.tonapi = createTonApiInstance( + tk.wallet.config.network === WalletNetwork.testnet, + ); } static fromJSON(json: any, vault: Vault): TonWallet { @@ -161,16 +149,12 @@ export class TonWallet { return new TonWallet(vault); } - toJSON(): any { - return {}; - } - getTonWeb() { return this.tonweb; } get isTestnet(): boolean { - return store.getState().main.isTestnet; + return tk.wallet.isTestnet; } get version(): string | undefined { @@ -217,7 +201,7 @@ export class TonWallet { async getSeqno(address: string): Promise { try { - const seqno = (await tonapi.wallet.getAccountSeqno(address)).seqno; + const seqno = (await this.tonapi.wallet.getAccountSeqno(address)).seqno; return seqno; } catch (err) { @@ -397,7 +381,6 @@ export class TonWallet { bounce: true, value: jettonTransferAmount, body: ContractService.createJettonTransferBody({ - queryId: Date.now(), jettonAmount, receiverAddress: recipient.address, excessesAddress: excessesAccount ?? tk.wallet.address.ton.raw, @@ -467,13 +450,20 @@ export class TonWallet { const secretKey = await unlockedVault.getTonPrivateKey(); - const { balances } = await Tonapi.getJettonBalances(sender.address); + await tk.wallet.jettons.load(); + + const balances = tk.wallet.jettons.state.data.jettonBalances; const balance = balances.find((jettonBalance) => - AddressFormatter.compare(jettonBalance.wallet_address.address, jettonWalletAddress), + AddressFormatter.compare(jettonBalance.walletAddress, jettonWalletAddress), ); - if (new BigNumber(balance.balance).lt(new BigNumber(amountNano))) { + if ( + !balance || + new BigNumber(toNano(balance.balance, balance.metadata.decimals ?? 9)).lt( + new BigNumber(amountNano), + ) + ) { throw new Error(t('send_insufficient_funds')); } @@ -578,7 +568,7 @@ export class TonWallet { vault: Vault, payload: string = '', ) { - const opTemplate = await tonapi.experimental.getInscriptionOpTemplate({ + const opTemplate = await this.tonapi.experimental.getInscriptionOpTemplate({ destination: address, amount, who: tk.wallet.address.ton.raw, @@ -649,7 +639,7 @@ export class TonWallet { vault: UnlockedVault, payload: string = '', ) { - const opTemplate = await tonapi.experimental.getInscriptionOpTemplate({ + const opTemplate = await this.tonapi.experimental.getInscriptionOpTemplate({ destination: address, amount, who: tk.wallet.address.ton.raw, diff --git a/packages/mobile/src/components/CardsWidget/CardsWidget.tsx b/packages/mobile/src/components/CardsWidget/CardsWidget.tsx new file mode 100644 index 000000000..e4b16f67d --- /dev/null +++ b/packages/mobile/src/components/CardsWidget/CardsWidget.tsx @@ -0,0 +1,30 @@ +import React, { memo } from 'react'; +import { OnboardBanner } from './components/OnboardBanner'; +import { Spacer, Steezy, View } from '@tonkeeper/uikit'; +import { CardsList } from './components/CardsList'; +import { tk } from '$wallet'; +import { useCardsState } from '$wallet/hooks'; + +export const CardsWidget = memo(() => { + const state = useCardsState(); + + return ( + + {!state.accounts.length && !state.onboardBannerDismissed ? ( + tk.wallet.cards.dismissOnboardBanner()} /> + ) : null} + {state.accounts.length ? ( + <> + + + + ) : null} + + ); +}); + +const styles = Steezy.create({ + container: { + paddingHorizontal: 16, + }, +}); diff --git a/packages/mobile/src/components/CardsWidget/components/CardsList.tsx b/packages/mobile/src/components/CardsWidget/components/CardsList.tsx new file mode 100644 index 000000000..bf546230a --- /dev/null +++ b/packages/mobile/src/components/CardsWidget/components/CardsList.tsx @@ -0,0 +1,105 @@ +import React, { memo, useCallback } from 'react'; +import { AccountState, CardKind } from '$wallet/managers/CardsManager'; +import { Icon, List, Steezy, View } from '@tonkeeper/uikit'; +import { formatter } from '@tonkeeper/shared/formatter'; +import { MainStackRouteNames } from '$navigation'; +import { useNavigation } from '@tonkeeper/router'; +import { Platform, Text } from 'react-native'; +import { DarkTheme } from '@tonkeeper/uikit/src/styles/themes/dark'; +import { CryptoCurrencies } from '$shared/constants'; +import { useGetTokenPrice } from '$hooks/useTokenPrice'; +import { capitalizeFirstLetter } from '@tonkeeper/shared/utils/date'; + +export interface CardsListProps { + accounts: AccountState[]; +} + +const colorsForCardIconBackground = [ + DarkTheme.accentGreen, + DarkTheme.accentOrange, + DarkTheme.accentBlue, + DarkTheme.accentRed, + DarkTheme.accentPurple, +]; + +function getColorByFourDigits(fourDigits: string | null | undefined) { + const sumOfDigits = + fourDigits + ?.split('') + .map(Number) + .reduce((acc, val) => acc + val, 0) ?? 0; + + return colorsForCardIconBackground[sumOfDigits % colorsForCardIconBackground.length]; +} + +const fontFamily = Platform.select({ + ios: 'SFMono-Bold', + android: 'RobotoMono-Bold', +}); + +export const CardsList = memo((props) => { + const cardNumberStyle = Steezy.useStyle(styles.cardNumber); + const nav = useNavigation(); + const openWebView = useCallback(() => { + nav.push(MainStackRouteNames.HoldersWebView); + }, [nav]); + const getTokenPrice = useGetTokenPrice(); + + const getPrice = useCallback( + (amount) => { + return getTokenPrice(CryptoCurrencies.Ton, amount); + }, + [getTokenPrice], + ); + + return ( + + {props.accounts.map((account) => + account.cards.map((card) => ( + + {card.lastFourDigits} + {card.kind === CardKind.VIRTUAL && ( + + )} + + } + onPress={openWebView} + value={`${formatter.fromNano(account.balance)} TON`} + subvalue={getPrice(formatter.fromNano(account.balance)).formatted.totalFiat} + subtitle={capitalizeFirstLetter(card.kind)} + title={`Card *${card.lastFourDigits}`} + /> + )), + )} + + ); +}); + +export const styles = Steezy.create(({ colors }) => ({ + cardIcon: { + height: 44, + width: 30, + borderRadius: 4, + paddingTop: 3, + paddingBottom: 2, + }, + cardNumber: { + fontFamily, + textAlign: 'center', + fontSize: 8.5, + lineHeight: 10, + color: colors.constantWhite, + }, + cloudIcon: { + position: 'absolute', + bottom: 2, + right: 4, + }, +})); diff --git a/packages/mobile/src/components/CardsWidget/components/OnboardBanner.tsx b/packages/mobile/src/components/CardsWidget/components/OnboardBanner.tsx new file mode 100644 index 000000000..e31e3d22b --- /dev/null +++ b/packages/mobile/src/components/CardsWidget/components/OnboardBanner.tsx @@ -0,0 +1,83 @@ +import React, { memo, useCallback } from 'react'; +import { + Button, + Icon, + Spacer, + Steezy, + Text, + TouchableOpacity, + View, +} from '@tonkeeper/uikit'; +import { LayoutAnimation, Animated } from 'react-native'; +import { useNavigation } from '@tonkeeper/router'; +import { MainStackRouteNames } from '$navigation'; + +const closeButtonHitSlop = { + top: 8, + left: 8, + right: 8, + bottom: 8, +}; + +export interface OnboardBannerProps { + onDismissBanner: () => void; +} + +export const OnboardBanner = memo((props) => { + const containerStyle = Steezy.useStyle(styles.container); + const nav = useNavigation(); + + const handleCloseBanner = useCallback(() => { + props.onDismissBanner(); + LayoutAnimation.easeInEaseOut(); + }, [props.onDismissBanner]); + + const openWebView = useCallback(() => { + nav.push(MainStackRouteNames.HoldersWebView); + }, []); + + return ( + + + + + Bank Card + + Pay in TON, convert to USD without commission. + + + ); - } else { - return ( - - ); } + + return null; } return ( @@ -268,7 +233,9 @@ export const AccessConfirmation: FC = () => { disabled={value.length === 4} onChange={handleKeyboard} value={value} - biometryEnabled={!isBiometryFailed} + biometryEnabled={ + biometry.isAvailable && biometry.isEnabled && !isBiometryFailed + } onBiometryPress={handleBiometry} /> diff --git a/packages/mobile/src/core/AddWatchOnly/AddWatchOnly.tsx b/packages/mobile/src/core/AddWatchOnly/AddWatchOnly.tsx new file mode 100644 index 000000000..a88c22da5 --- /dev/null +++ b/packages/mobile/src/core/AddWatchOnly/AddWatchOnly.tsx @@ -0,0 +1,199 @@ +import { SendRecipient } from '$core/Send/Send.interface'; +import { AddressInput } from '$core/Send/steps/AddressStep/components'; +import { Tonapi } from '$libs/Tonapi'; +import { openSetupNotifications, openSetupWalletDone } from '$navigation'; +import { asyncDebounce, isTransferOp, parseTonLink } from '$utils'; +import { tk } from '$wallet'; +import { Address } from '@tonkeeper/core'; +import { t } from '@tonkeeper/shared/i18n'; +import { + Button, + Screen, + Spacer, + Steezy, + Text, + Toast, + View, + useReanimatedKeyboardHeight, +} from '@tonkeeper/uikit'; +import React, { FC, useCallback, useMemo, useState } from 'react'; +import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +let dnsAbortController: null | AbortController = null; + +export const AddWatchOnly: FC = () => { + const [account, setAccount] = useState | null>(null); + const [dnsLoading, setDnsLoading] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + const getAddressByDomain = useMemo( + () => + asyncDebounce(async (value: string, signal: AbortSignal) => { + try { + const domain = value.toLowerCase(); + const resolvedDomain = await Tonapi.resolveDns(domain, signal); + + if (resolvedDomain === 'aborted') { + return 'aborted'; + } else if (resolvedDomain?.wallet?.address) { + return resolvedDomain.wallet.address as string; + } + + return null; + } catch (e) { + console.log('err', e); + + return null; + } + }, 1000), + [], + ); + + const validate = useCallback( + async (value: string) => { + setError(false); + if (value.length === 0) { + setAccount(null); + + return false; + } + + try { + const link = parseTonLink(value); + + if (dnsAbortController) { + dnsAbortController.abort(); + dnsAbortController = null; + setDnsLoading(false); + } + + if (link.match && isTransferOp(link.operation) && Address.isValid(link.address)) { + if (link.query.bin) { + return false; + } + + value = link.address; + } + + if (Address.isValid(value)) { + setAccount({ address: value }); + + return true; + } + + const domain = value.toLowerCase(); + + if (!Address.isValid(domain)) { + setDnsLoading(true); + const abortController = new AbortController(); + dnsAbortController = abortController; + + const zone = domain.indexOf('.') === -1 ? '.ton' : ''; + const resolvedDomain = await getAddressByDomain( + domain + zone, + abortController.signal, + ); + + if (resolvedDomain === 'aborted') { + setDnsLoading(false); + dnsAbortController = null; + return true; + } else if (resolvedDomain) { + setAccount({ address: resolvedDomain, domain }); + setDnsLoading(false); + dnsAbortController = null; + return true; + } else { + setDnsLoading(false); + dnsAbortController = null; + } + } + + setAccount(null); + + return false; + } catch (e) { + return false; + } + }, + [getAddressByDomain, setAccount], + ); + + const handleContinue = useCallback(async () => { + if (!account) { + return; + } + + setLoading(true); + + try { + const identifiers = await tk.addWatchOnlyWallet(account.address); + const isNotificationsDenied = await tk.wallet.notifications.getIsDenied(); + + if (isNotificationsDenied) { + openSetupWalletDone(identifiers); + } else { + openSetupNotifications(identifiers); + } + } catch (e) { + if (e.error) { + Toast.fail(t('add_watch_only.wallet_not_found')); + } + setLoading(false); + setError(true); + } + }, [account]); + + const { spacerStyle } = useReanimatedKeyboardHeight(); + + return ( + + + + + + + {t('add_watch_only.title')} + + + + {t('add_watch_only.subtitle')} + + + + + + + - - ); - } - - return ( - <> - - {renderContent()} - - ); -}; diff --git a/packages/mobile/src/core/ChooseCurrencyScreen.tsx b/packages/mobile/src/core/ChooseCurrencyScreen.tsx index fcf92bb4e..43ec19f77 100644 --- a/packages/mobile/src/core/ChooseCurrencyScreen.tsx +++ b/packages/mobile/src/core/ChooseCurrencyScreen.tsx @@ -2,24 +2,29 @@ import React from 'react'; import { Icon, Screen, Text } from '$uikit'; import { StyleSheet, View } from 'react-native'; import { ns } from '$utils'; -import { useDispatch, useSelector } from 'react-redux'; -import { mainActions, mainSelector } from '$store/main'; import { t } from '@tonkeeper/shared/i18n'; import { CellSection, CellSectionItem } from '$shared/components'; -import { FiatCurrencySymbolsConfig, FiatCurrencies } from '@tonkeeper/core'; +import { FiatCurrencySymbolsConfig, WalletCurrency } from '@tonkeeper/core'; +import { tk } from '$wallet'; +import { useWalletCurrency, useWallets } from '@tonkeeper/shared/hooks'; export const ChooseCurrencyScreen: React.FC = () => { - const { fiatCurrency } = useSelector(mainSelector); - const dispatch = useDispatch(); + const fiatCurrency = useWalletCurrency(); const currencies = React.useMemo(() => { - return Object.keys(FiatCurrencySymbolsConfig) as FiatCurrencies[]; + return Object.keys(FiatCurrencySymbolsConfig) as WalletCurrency[]; }, []); + const wallets = useWallets(); const handleChangeCurrency = React.useCallback( - (currency: FiatCurrencies) => { - dispatch(mainActions.setFiatCurrency(currency)); + (currency: WalletCurrency) => { + tk.tonPrice.setFiatCurrency(currency); + tk.tonPrice.load(); + + wallets.forEach((wallet) => { + wallet.jettons.reload(); + }); }, - [dispatch], + [wallets], ); return ( diff --git a/packages/mobile/src/core/CreatePin/CreatePin.interface.ts b/packages/mobile/src/core/CreatePin/CreatePin.interface.ts deleted file mode 100644 index ae53ce39f..000000000 --- a/packages/mobile/src/core/CreatePin/CreatePin.interface.ts +++ /dev/null @@ -1 +0,0 @@ -export interface CreatePinProps {} diff --git a/packages/mobile/src/core/CreatePin/CreatePin.tsx b/packages/mobile/src/core/CreatePin/CreatePin.tsx index 68dd4930b..07c69aa1f 100644 --- a/packages/mobile/src/core/CreatePin/CreatePin.tsx +++ b/packages/mobile/src/core/CreatePin/CreatePin.tsx @@ -1,61 +1,51 @@ import React, { FC, useCallback } from 'react'; -import * as LocalAuthentication from 'expo-local-authentication'; import { useDispatch } from 'react-redux'; -import * as SecureStore from 'expo-secure-store'; -import { CreatePinProps } from './CreatePin.interface'; import * as S from '../AccessConfirmation/AccessConfirmation.style'; import { NavBar } from '$uikit'; -import { detectBiometryType } from '$utils'; -import { debugLog } from '$utils/debugLog'; -import { openSetupBiometry, openSetupWalletDone } from '$navigation'; +import { openSetupNotifications, openSetupWalletDone } from '$navigation'; import { walletActions } from '$store/wallet'; import { CreatePinForm } from '$shared/components'; +import { tk } from '$wallet'; +import { popToTop } from '$navigation/imperative'; +import { useParams } from '@tonkeeper/router/src/imperative'; +import { BlockingLoader } from '@tonkeeper/uikit'; -export const CreatePin: FC = () => { +export const CreatePin: FC = () => { + const params = useParams<{ isImport?: boolean }>(); const dispatch = useDispatch(); - const doCreateWallet = useCallback( - (pin: string) => { + const isImport = !!params.isImport; + + const handlePinCreated = useCallback( + async (pin: string) => { + BlockingLoader.show(); dispatch( walletActions.createWallet({ pin, - onDone: () => { - openSetupWalletDone(); + onDone: (identifiers) => { + if (isImport) { + tk.saveLastBackupTimestampAll(identifiers); + } + if (tk.wallet.notifications.isAvailable) { + openSetupNotifications(identifiers); + } else { + openSetupWalletDone(identifiers); + } + BlockingLoader.hide(); + }, + onFail: () => { + BlockingLoader.hide(); }, - onFail: () => {}, }), ); }, - [dispatch], - ); - - const handlePinCreated = useCallback( - (pin: string) => { - Promise.all([ - LocalAuthentication.supportedAuthenticationTypesAsync(), - SecureStore.isAvailableAsync(), - ]) - .then(([types, isProtected]) => { - const biometryType = detectBiometryType(types); - if (biometryType && isProtected) { - openSetupBiometry(pin, biometryType); - } else { - doCreateWallet(pin); - } - }) - .catch((err) => { - console.log('ERR1', err); - debugLog('supportedAuthenticationTypesAsync', err.message); - doCreateWallet(pin); - }); - }, - [doCreateWallet], + [dispatch, isImport], ); return ( - + ); diff --git a/packages/mobile/src/core/CreateWallet/CreateWallet.style.ts b/packages/mobile/src/core/CreateWallet/CreateWallet.style.ts deleted file mode 100644 index 4302f6160..000000000 --- a/packages/mobile/src/core/CreateWallet/CreateWallet.style.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Animated from 'react-native-reanimated'; -import LottieView from 'lottie-react-native'; - -import styled from '$styled'; -import { nfs, ns } from '$utils'; - -export const Wrap = styled.View` - flex: 1; -`; - -export const Step = styled(Animated.View)` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -`; - -export const Content = styled.View` - flex: 1; - align-items: center; - justify-content: center; - padding: ${ns(32)}px; -`; - -export const Icon = styled(Animated.View)` - width: ${ns(84)}px; - height: ${ns(84)}px; -`; - -export const TitleWrapper = styled.View` - margin-top: ${ns(16)}px; -`; - -export const CaptionWrapper = styled.View` - margin-top: ${ns(4)}px; -`; - -export const ButtonWrap = styled.View` - padding-horizontal: ${ns(32)}px; - height: ${ns(56)}px; -`; - -export const LottieIcon = styled(LottieView)` - width: ${ns(128)}px; - height: ${ns(128)}px; -`; diff --git a/packages/mobile/src/core/CreateWallet/CreateWallet.tsx b/packages/mobile/src/core/CreateWallet/CreateWallet.tsx deleted file mode 100644 index 9b74dcde1..000000000 --- a/packages/mobile/src/core/CreateWallet/CreateWallet.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { - Easing, - interpolate, - runOnJS, - useAnimatedStyle, - useSharedValue, - withDelay, - withTiming, -} from 'react-native-reanimated'; -import LottieView from 'lottie-react-native'; - -import * as S from './CreateWallet.style'; -import { walletActions, walletSelector } from '$store/wallet'; -import { Text } from '$uikit/Text/Text'; -import { Button, } from '$uikit/Button/Button'; -import { NavBar } from '$uikit/NavBar/NavBar'; -import { deviceWidth, ns, triggerNotificationSuccess } from '$utils'; -import { AppStackRouteNames, SetupWalletStackRouteNames, openSecretWords } from '$navigation'; -import { navigate } from '$navigation/imperative'; -import { t } from '@tonkeeper/shared/i18n'; - -export const CreateWallet: FC = () => { - const dispatch = useDispatch(); - const { bottom } = useSafeAreaInsets(); - const { generatedVault } = useSelector(walletSelector); - const [step, setStep] = useState(1); - const iconRef = useRef(null); - const checkIconRef = useRef(null); - const slideAnimation = useSharedValue(0); - - useEffect(() => { - const timer = setTimeout(() => { - dispatch(walletActions.generateVault()); - }, 1000); - return () => clearTimeout(timer); - }, []); - - const handleDoneStep1Anim = useCallback(() => { - setStep(2); - checkIconRef.current?.play(); - triggerNotificationSuccess(); - }, [checkIconRef]); - - const handleDoneStep3Anim = useCallback(() => { - iconRef.current?.play(); - }, []); - - useEffect(() => { - if (step === 1) { - if (generatedVault) { - slideAnimation.value = withTiming( - 1, - { - duration: 350, - easing: Easing.inOut(Easing.ease), - }, - (isFinished) => { - if (isFinished) { - runOnJS(handleDoneStep1Anim)(); - } - }, - ); - } - } else if (step === 2) { - slideAnimation.value = withDelay( - 2500, - withTiming( - 2, - { - duration: 350, - easing: Easing.inOut(Easing.ease), - }, - (isFinished) => { - if (isFinished) { - runOnJS(handleDoneStep3Anim)(); - } - }, - ), - ); - } - }, [generatedVault, step]); - - useEffect(() => { - let timer: any = 0; - if (step === 2) { - timer = setTimeout(() => { - setStep(3); - }, 3000); - } - - return () => clearTimeout(timer); - }, [step]); - - const handleContinue = useCallback(() => { - openSecretWords(); - }, []); - - const step1Style = useAnimatedStyle(() => ({ - opacity: interpolate(Math.min(slideAnimation.value, 1), [0, 1], [1, 0]), - transform: [ - { - translateX: interpolate( - Math.min(slideAnimation.value, 1), - [0, 1], - [0, -deviceWidth], - ), - }, - ], - })); - - const step2Style = useAnimatedStyle(() => ({ - opacity: interpolate(Math.min(slideAnimation.value, 2), [0, 1, 2], [0, 1, 0]), - transform: [ - { - translateX: interpolate( - Math.min(slideAnimation.value, 2), - [0, 1, 2], - [deviceWidth, 0, -deviceWidth], - ), - }, - ], - })); - - const step3Style = useAnimatedStyle(() => { - const value = Math.min(Math.max(1, slideAnimation.value), 2); - return { - opacity: interpolate(value, [1, 2], [0, 1]), - transform: [ - { - translateX: interpolate(value, [1, 2], [deviceWidth, 0]), - }, - ], - }; - }); - - return ( - - - - - - - - {t('create_wallet_generating')} - - - - - - - - - - {t('create_wallet_generated')} - - - - - - - - - - {t('create_wallet_title')} - - - - - {t('create_wallet_caption')} - - - - - - - - - ); -}; - -export function openCreateWallet() { - navigate(AppStackRouteNames.SetupWalletStack, { - screen: SetupWalletStackRouteNames.CreateWallet, - }); -} \ No newline at end of file diff --git a/packages/mobile/src/core/CustomizeWallet/CustomizeWallet.tsx b/packages/mobile/src/core/CustomizeWallet/CustomizeWallet.tsx new file mode 100644 index 000000000..a4c9029e5 --- /dev/null +++ b/packages/mobile/src/core/CustomizeWallet/CustomizeWallet.tsx @@ -0,0 +1,292 @@ +import { NavBar } from '$uikit'; +import { tk } from '$wallet'; +import { useNavigation } from '@tonkeeper/router'; +import { useWallet } from '@tonkeeper/shared/hooks'; +import { t } from '@tonkeeper/shared/i18n'; +import { + Button, + Haptics, + Input, + Modal, + Spacer, + Steezy, + Text, + TouchableOpacity, + View, + WalletColor, + getWalletColorHex, + isAndroid, + ns, + useReanimatedKeyboardHeight, + useTheme, +} from '@tonkeeper/uikit'; +import React, { + FC, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Keyboard, LayoutChangeEvent, Text as RNText } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { EmojiPicker } from './EmojiPicker'; +import { BottomButtonWrapHelper } from '$shared/components'; +import LinearGradient from 'react-native-linear-gradient'; +import { RouteProp } from '@react-navigation/native'; +import { AppStackParamList } from '$navigation/AppStack'; +import { AppStackRouteNames } from '$navigation'; + +const COLORS_LIST = Object.values(WalletColor); + +const COLOR_ITEM_WIDTH = ns(36) + ns(12); + +interface Props { + route: RouteProp; +} + +export const CustomizeWallet: FC = memo((props) => { + const identifiers = useMemo( + () => props.route?.params?.identifiers ?? [tk.wallet.identifier], + [props], + ); + + const wallet = useWallet(); + const nav = useNavigation(); + const theme = useTheme(); + + const [name, setName] = useState( + identifiers.length > 1 ? wallet.config.name.slice(0, -5) : wallet.config.name, + ); + const [selectedColor, setSelectedColor] = useState(wallet.config.color); + const [emoji, setEmoji] = useState(wallet.config.emoji); + const [keyboardShown, setKeyboardShown] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); + + const colorsScrollViewRef = useRef(null); + + const { spacerStyle } = useReanimatedKeyboardHeight(); + + const handleSave = useCallback(() => { + tk.updateWallet({ name: name.trim(), color: selectedColor, emoji }, identifiers); + nav.goBack(); + }, [emoji, identifiers, name, nav, selectedColor]); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + setContainerWidth(event.nativeEvent.layout.width); + }, []); + + const handleEmojiChange = useCallback((value: string) => { + Haptics.selection(); + setEmoji(value); + }, []); + + const handleChangeColor = useCallback((color: WalletColor) => { + Haptics.selection(); + setSelectedColor(color); + }, []); + + const pickersAnimatedStyles = useAnimatedStyle( + () => ({ + opacity: withTiming(keyboardShown ? 0.32 : 1, { duration: 200 }), + }), + [keyboardShown], + ); + + useEffect(() => { + if (containerWidth === 0) { + return; + } + + const selectedColorIndex = COLORS_LIST.indexOf(selectedColor); + + colorsScrollViewRef.current?.scrollTo({ + x: (selectedColorIndex - 3) * COLOR_ITEM_WIDTH, + animated: false, + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerWidth]); + + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener( + isAndroid ? 'keyboardDidShow' : 'keyboardWillShow', + () => { + setKeyboardShown(true); + }, + ); + + const keyboardDidHideListener = Keyboard.addListener( + isAndroid ? 'keyboardDidHide' : 'keyboardWillHide', + () => { + setKeyboardShown(false); + }, + ); + + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, []); + + return ( + + + + + + {t('customize_modal.title')} + + + + {t('customize_modal.subtitle')} + + + + + + + {emoji} + + + + + + + + + {COLORS_LIST.map((color, index) => ( + + {index > 0 ? : null} + handleChangeColor(color)} + activeOpacity={0.5} + > + + {color === selectedColor ? ( + + ) : null} + + + + ))} + + + + + + + + + - + @@ -183,7 +227,7 @@ export const DevDeeplinking: React.FC = () => { - - - - - - - - - ); -}; diff --git a/packages/mobile/src/core/Exchange/Exchange.tsx b/packages/mobile/src/core/Exchange/Exchange.tsx index d8e9e0f1f..7d061f646 100644 --- a/packages/mobile/src/core/Exchange/Exchange.tsx +++ b/packages/mobile/src/core/Exchange/Exchange.tsx @@ -3,20 +3,20 @@ import React, { FC, useCallback } from 'react'; import { InlineHeader, Loader } from '$uikit'; import * as S from './Exchange.style'; import { ExchangeItem } from './ExchangeItem/ExchangeItem'; -import { getServerConfig, getServerConfigSafe } from '$shared/constants'; import { Linking } from 'react-native'; import { Modal } from '@tonkeeper/uikit'; import { useMethodsToBuyStore } from '$store/zustand/methodsToBuy/useMethodsToBuyStore'; import { t } from '@tonkeeper/shared/i18n'; +import { config } from '$config'; export const OldExchange: FC = () => { const categories = useMethodsToBuyStore((state) => state.categories); - const otherWaysAvailable = getServerConfigSafe('exchangePostUrl') !== 'none'; + const otherWaysAvailable = !!config.get('exchangePostUrl'); const openOtherWays = useCallback(() => { try { - const url = getServerConfig('exchangePostUrl'); + const url = config.get('exchangePostUrl'); Linking.openURL(url); } catch {} diff --git a/packages/mobile/src/core/HideableAmount/HideableAmount.tsx b/packages/mobile/src/core/HideableAmount/HideableAmount.tsx index 9226a2f51..745d4be96 100644 --- a/packages/mobile/src/core/HideableAmount/HideableAmount.tsx +++ b/packages/mobile/src/core/HideableAmount/HideableAmount.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { Text } from '$uikit/Text/Text'; import { TextProps } from '$uikit/Text/Text'; -import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated'; -import { Steezy } from '@tonkeeper/uikit'; -import { useHideableAmount } from '$core/HideableAmount/HideableAmountProvider'; +import { usePrivacyStore } from '$store/zustand/privacy/usePrivacyStore'; export enum AnimationDirection { Left = -1, @@ -13,61 +11,14 @@ export enum AnimationDirection { const HideableAmountComponent: React.FC< TextProps & { stars?: string; animationDirection?: AnimationDirection } -> = ({ - children, - style, - animationDirection = AnimationDirection.Right, - stars = '* * *', - ...rest -}) => { - const animationProgress = useHideableAmount(); - - const translateXTo = 10 * animationDirection; - - const amountStyle = useAnimatedStyle(() => { - return { - display: animationProgress.value < 0.5 ? 'flex' : 'none', - opacity: interpolate(animationProgress.value, [0, 0.5], [1, 0]), - transform: [ - { - translateX: interpolate(animationProgress.value, [0, 0.5], [0, translateXTo]), - }, - { - scale: interpolate(animationProgress.value, [0, 0.5], [1, 0.85]), - }, - ], - }; - }); - - const starsStyle = useAnimatedStyle(() => { - return { - display: animationProgress.value > 0.5 ? 'flex' : 'none', - opacity: interpolate(animationProgress.value, [1, 0.5], [1, 0]), - transform: [ - { - translateX: interpolate(animationProgress.value, [1, 0.5], [0, translateXTo]), - }, - { - scale: interpolate(animationProgress.value, [1, 0.5], [1, 0.85]), - }, - ], - }; - }); +> = ({ children, style, stars = '* * *', ...rest }) => { + const isHidden = usePrivacyStore((state) => state.hiddenAmounts); return ( - - - {children} - - - {stars} - - + + {isHidden ? stars : children} + ); }; export const HideableAmount = React.memo(HideableAmountComponent); - -const styles = Steezy.create({ - stars: {}, -}); diff --git a/packages/mobile/src/core/HideableAmount/ShowBalance.tsx b/packages/mobile/src/core/HideableAmount/ShowBalance.tsx index f898d919d..d409a0f56 100644 --- a/packages/mobile/src/core/HideableAmount/ShowBalance.tsx +++ b/packages/mobile/src/core/HideableAmount/ShowBalance.tsx @@ -1,76 +1,52 @@ -import React, { useCallback, useContext } from 'react'; -import { AnimationDirection, HideableAmount } from '$core/HideableAmount/HideableAmount'; +import React, { useCallback } from 'react'; import { TouchableHighlight, TouchableOpacity } from 'react-native-gesture-handler'; import { usePrivacyStore } from '$store/zustand/privacy/usePrivacyStore'; -import { - HideableAmountContext, - useHideableAmount, -} from '$core/HideableAmount/HideableAmountProvider'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { Steezy } from '$styles'; import { Pressable, View } from '$uikit'; -import { useTheme } from '$hooks/useTheme'; import { Haptics, isAndroid } from '$utils'; import { DarkTheme } from '$styled'; +import { Text } from '@tonkeeper/uikit'; const TouchableComponent = isAndroid ? Pressable : TouchableHighlight; export const ShowBalance: React.FC<{ amount: string }> = ({ amount }) => { const hideAmounts = usePrivacyStore((state) => state.actions.toggleHiddenAmounts); - const animationProgress = useHideableAmount(); - const { colors } = useTheme(); + const isHidden = usePrivacyStore((state) => state.hiddenAmounts); const handleToggleHideAmounts = useCallback(() => { hideAmounts(); Haptics.impactHeavy(); }, [hideAmounts]); - const touchableOpacityStyle = useAnimatedStyle(() => { - return { - display: animationProgress.value < 0.5 ? 'flex' : 'none', - }; - }, []); - - const pressableStyle = useAnimatedStyle(() => { - return { - backgroundColor: colors.backgroundSecondary, - display: animationProgress.value >= 0.5 ? 'flex' : 'none', - }; - }, []); - return ( - + {isHidden ? ( + + + + {'* * *'} + + + + ) : ( - - {amount} - + {amount} - - - - - {amount} - - - + )} ); }; -const styles = Steezy.create({ +const styles = Steezy.create(({ colors }) => ({ container: { height: 36, }, starsContainer: { + backgroundColor: colors.backgroundSecondary, borderRadius: 100, }, touchable: { @@ -80,4 +56,4 @@ const styles = Steezy.create({ stars: { paddingTop: 5.5, }, -}); +})); diff --git a/packages/mobile/src/core/ImportWallet/ImportWallet.tsx b/packages/mobile/src/core/ImportWallet/ImportWallet.tsx index 203c14841..588dd9151 100644 --- a/packages/mobile/src/core/ImportWallet/ImportWallet.tsx +++ b/packages/mobile/src/core/ImportWallet/ImportWallet.tsx @@ -1,31 +1,62 @@ import React, { FC, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; import * as S from './ImportWallet.style'; import { NavBar } from '$uikit'; import { useKeyboardHeight } from '$hooks/useKeyboardHeight'; -import { walletActions } from '$store/wallet'; -import { openCreatePin } from '$navigation'; import { ImportWalletForm } from '$shared/components'; +import { RouteProp } from '@react-navigation/native'; +import { + ImportWalletStackParamList, + ImportWalletStackRouteNames, +} from '$navigation/ImportWalletStack/types'; +import { useNavigation } from '@tonkeeper/router'; +import { useImportWallet } from '$hooks/useImportWallet'; +import { tk } from '$wallet'; +import { ImportWalletInfo } from '$wallet/WalletTypes'; +import { DEFAULT_WALLET_VERSION } from '$wallet/constants'; -export const ImportWallet: FC = () => { - const dispatch = useDispatch(); +export const ImportWallet: FC<{ + route: RouteProp; +}> = (props) => { const keyboardHeight = useKeyboardHeight(); + const nav = useNavigation(); + const doImportWallet = useImportWallet(); + + const isTestnet = !!props.route.params?.testnet; const handleWordsFilled = useCallback( - (mnemonics: string, config: any, onEnd: () => void) => { - dispatch( - walletActions.restoreWallet({ - mnemonics, - config, - onDone: () => { - onEnd(); - openCreatePin(); - }, - onFail: () => onEnd(), - }), - ); + async (mnemonic: string, lockupConfig: any, onEnd: () => void) => { + try { + let walletsInfo: ImportWalletInfo[] | null = null; + + try { + walletsInfo = await tk.getWalletsInfo(mnemonic, isTestnet); + } catch {} + + const shouldChooseWallets = + !lockupConfig && walletsInfo && walletsInfo.length > 1; + + if (shouldChooseWallets) { + nav.navigate(ImportWalletStackRouteNames.ChooseWallets, { + walletsInfo, + mnemonic, + lockupConfig, + isTestnet, + }); + onEnd(); + return; + } + + const versions = walletsInfo + ? walletsInfo.map((item) => item.version) + : [DEFAULT_WALLET_VERSION]; + + await doImportWallet(mnemonic, lockupConfig, versions, isTestnet); + onEnd(); + } catch { + onEnd(); + } }, - [dispatch], + [doImportWallet, isTestnet, nav], ); return ( diff --git a/packages/mobile/src/core/InscriptionScreen.tsx b/packages/mobile/src/core/InscriptionScreen.tsx index 2b0645fe6..ab0a3ec8e 100644 --- a/packages/mobile/src/core/InscriptionScreen.tsx +++ b/packages/mobile/src/core/InscriptionScreen.tsx @@ -16,6 +16,7 @@ import { t } from '@tonkeeper/shared/i18n'; import { openSend } from '$navigation'; import { TokenType } from '$core/Send/Send.interface'; import { openReceiveInscriptionModal } from '@tonkeeper/shared/modals/ReceiveInscriptionModal'; +import { useWallet } from '@tonkeeper/shared/hooks'; export const InscriptionScreen = memo(() => { const params = useParams<{ ticker: string; type: string }>(); @@ -24,6 +25,8 @@ export const InscriptionScreen = memo(() => { throw Error('Wrong parameters'); } + const wallet = useWallet(); + const inscription = useTonInscription({ ticker: params.ticker, type: params.type }); const handleSend = useCallback(() => { @@ -41,16 +44,8 @@ export const InscriptionScreen = memo(() => { return ( - - {inscription.ticker} - - - {inscription.type.toUpperCase()} - - - } + subtitle={inscription.type.toUpperCase()} + title={inscription.ticker} /> @@ -69,11 +64,13 @@ export const InscriptionScreen = memo(() => { - + {!wallet.isWatchOnly ? ( + + ) : null} { - const theme = useTheme(); - const dispatch = useDispatch(); - - const handleContinue = useCallback(() => { - dispatch(mainActions.completeIntro()); - }, [dispatch]); - - return ( - - - - {t('intro_title')} - - {t('app_name')} - - - - - - - {t('intro_item1_title')} - - {t('intro_item1_caption')} - - - - - - - {t('intro_item2_title')} - - {t('intro_item2_caption')} - - - - {/* - - - - {t('intro_item3_title')} - {t('intro_item3_caption')} - - - */} - - - - - - - - ); -}; diff --git a/packages/mobile/src/core/Jetton/Jetton.style.ts b/packages/mobile/src/core/Jetton/Jetton.style.ts index 3c5797a09..e244d6eb5 100644 --- a/packages/mobile/src/core/Jetton/Jetton.style.ts +++ b/packages/mobile/src/core/Jetton/Jetton.style.ts @@ -9,6 +9,11 @@ export const Wrap = styled.View` flex: 1; `; +export const ChartWrap = styled.View` + margin-bottom: ${ns(24.5)}px; + margin-top: 18px; +`; + export const HeaderWrap = styled.View` align-items: center; padding-horizontal: ${ns(16)}px; @@ -101,15 +106,13 @@ export const ActionLabelWrapper = styled.View` margin-top: ${ns(2)}px; `; - export const ActionsContainer = styled.View` justify-content: center; flex-direction: row; margin-bottom: ${ns(12)}px; `; -export const IconWrap = styled.View` -`; +export const IconWrap = styled.View``; export const HeaderViewDetailsButton = styled(TouchableOpacity).attrs({ activeOpacity: Opacity.ForSmall, diff --git a/packages/mobile/src/core/Jetton/Jetton.tsx b/packages/mobile/src/core/Jetton/Jetton.tsx index 93476652f..24c9634a5 100644 --- a/packages/mobile/src/core/Jetton/Jetton.tsx +++ b/packages/mobile/src/core/Jetton/Jetton.tsx @@ -6,10 +6,7 @@ import { ns } from '$utils'; import { useJetton } from '$hooks/useJetton'; import { useTokenPrice } from '$hooks/useTokenPrice'; import { openDAppBrowser, openSend } from '$navigation'; -import { getServerConfig } from '$shared/constants'; -import { useSelector } from 'react-redux'; -import { walletAddressSelector } from '$store/wallet'; import { formatter } from '$utils/formatter'; import { useNavigation } from '@tonkeeper/router'; import { useSwapStore } from '$store/zustand/swap'; @@ -20,20 +17,32 @@ import { Events, JettonVerification, SendAnalyticsFrom } from '$store/models'; import { t } from '@tonkeeper/shared/i18n'; import { trackEvent } from '$utils/stats'; import { Address } from '@tonkeeper/core'; -import { Screen, Steezy, View, Icon, Spacer } from '@tonkeeper/uikit'; +import { Icon, Screen, Spacer, Steezy, TouchableOpacity, View } from '@tonkeeper/uikit'; import { useJettonActivityList } from '@tonkeeper/shared/query/hooks/useJettonActivityList'; import { ActivityList } from '@tonkeeper/shared/components'; import { openReceiveJettonModal } from '@tonkeeper/shared/modals/ReceiveJettonModal'; import { TokenType } from '$core/Send/Send.interface'; -import { config } from '@tonkeeper/shared/config'; +import { config } from '$config'; +import { openUnverifiedTokenDetailsModal } from '@tonkeeper/shared/modals/UnverifiedTokenDetailsModal'; +import { useWallet, useWalletCurrency } from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; +import { Chart } from '$shared/components/Chart/new/Chart'; +import { ChartPeriod } from '$store/zustand/chart'; + +const unverifiedTokenHitSlop = { top: 4, left: 4, bottom: 4, right: 4 }; export const Jetton: React.FC = ({ route }) => { const flags = useFlags(['disable_swap']); const jetton = useJetton(route.params.jettonAddress); const jettonActivityList = useJettonActivityList(jetton.jettonAddress); - const address = useSelector(walletAddressSelector); const jettonPrice = useTokenPrice(jetton.jettonAddress, jetton.balance); + const wallet = useWallet(); + + const isWatchOnly = wallet && wallet.isWatchOnly; + const fiatCurrency = useWalletCurrency(); + const shouldShowChart = jettonPrice.fiat !== 0; + const shouldExcludeChartPeriods = config.get('exclude_jetton_chart_periods'); const nav = useNavigation(); @@ -58,10 +67,11 @@ export const Jetton: React.FC = ({ route }) => { const handleOpenExplorer = useCallback(async () => { openDAppBrowser( - getServerConfig('accountExplorer').replace('%s', address.ton) + - `/jetton/${jetton.jettonAddress}`, + config + .get('accountExplorer', tk.wallet.isTestnet) + .replace('%s', wallet.address.ton.friendly) + `/jetton/${jetton.jettonAddress}`, ); - }, [address.ton, jetton.jettonAddress]); + }, [jetton.jettonAddress, wallet]); const renderHeader = useMemo(() => { if (!jetton) { @@ -97,17 +107,19 @@ export const Jetton: React.FC = ({ route }) => { - + {!isWatchOnly ? ( + + ) : null} - {showSwap && !flags.disable_swap ? ( + {!isWatchOnly && showSwap && !flags.disable_swap ? ( } @@ -115,18 +127,37 @@ export const Jetton: React.FC = ({ route }) => { /> ) : null} - + + {shouldShowChart && ( + <> + + + + + + )} ); }, [ jetton, - t, jettonPrice, + isWatchOnly, handleSend, handleReceive, showSwap, flags.disable_swap, handlePressSwap, + shouldShowChart, + fiatCurrency, + route.params.jettonAddress, ]); if (!jetton) { @@ -139,15 +170,19 @@ export const Jetton: React.FC = ({ route }) => { subtitle={ !config.get('disable_show_unverified_token') && jetton.verification === JettonVerification.NONE && ( - - - - - + {t('approval.unverified_token')} - + + + + + ) } title={jetton.metadata?.name || Address.toShort(jetton.jettonAddress)} @@ -159,12 +194,12 @@ export const Jetton: React.FC = ({ route }) => { shouldCloseMenu onPress={handleOpenExplorer} text={t('jetton_open_explorer')} - icon={} + icon={} />, ]} > null}> - + } @@ -192,4 +227,10 @@ const styles = Steezy.create({ iconContainer: { marginTop: 2, }, + mb10: { + marginBottom: 10, + }, + mb0: { + marginBottom: 0, + }, }); diff --git a/packages/mobile/src/core/JettonsList/JettonsList.interface.ts b/packages/mobile/src/core/JettonsList/JettonsList.interface.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/mobile/src/core/JettonsList/JettonsList.style.ts b/packages/mobile/src/core/JettonsList/JettonsList.style.ts deleted file mode 100644 index c4d71ced5..000000000 --- a/packages/mobile/src/core/JettonsList/JettonsList.style.ts +++ /dev/null @@ -1,74 +0,0 @@ -import styled, { RADIUS } from '$styled'; -import { hNs, nfs, ns } from '$utils'; -import FastImage from 'react-native-fast-image'; - -const borders = (borderStart: boolean, borderEnd: boolean) => { - return ` - ${ - borderStart - ? ` - border-top-left-radius: ${ns(RADIUS.normal)}px; - border-top-right-radius: ${ns(RADIUS.normal)}px; - ` - : '' - } - ${ - borderEnd - ? ` - border-bottom-left-radius: ${ns(RADIUS.normal)}px; - border-bottom-right-radius: ${ns(RADIUS.normal)}px; - ` - : '' - } - `; -}; - -export const Wrap = styled.View` - flex: 1; -`; - -export const JettonInner = styled.View<{ isFirst: boolean; isLast: boolean }>` - flex-direction: row; - align-items: center; - padding: ${ns(16)}px; - background: ${({ theme }) => theme.colors.backgroundSecondary}; - ${({ isFirst, isLast }) => borders(isFirst, isLast)} -`; - -export const JettonCont = styled.View` - flex: 1; -`; - -export const JettonName = styled.Text.attrs({ - numberOfLines: 1, -})` - font-family: ${({ theme }) => theme.font.medium}; - color: ${({ theme }) => theme.colors.foregroundPrimary}; - font-size: ${nfs(16)}px; - line-height: 24px; - margin-right: ${ns(16)}px; -`; - -export const JettonInfo = styled.Text.attrs({ - numberOfLines: 1, -})` - font-family: ${({ theme }) => theme.font.regular}; - color: ${({ theme }) => theme.colors.foregroundSecondary}; - font-size: ${nfs(14)}px; - line-height: 20px; -`; - -export const BalanceWrapper = styled.View` - margin-top: ${ns(2)}px; -`; - -export const JettonLogo = styled(FastImage).attrs({ - resizeMode: 'stretch', -})` - z-index: 2; - height: ${ns(44)}px; - width: ${hNs(44)}px; - border-radius: ${ns(44 / 2)}px; - background: ${({ theme }) => theme.colors.backgroundTertiary}; - margin-right: ${ns(16)}px; -`; diff --git a/packages/mobile/src/core/JettonsList/JettonsList.tsx b/packages/mobile/src/core/JettonsList/JettonsList.tsx deleted file mode 100644 index 769b4a0c8..000000000 --- a/packages/mobile/src/core/JettonsList/JettonsList.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { FC, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import * as S from './JettonsList.style'; -import { AnimatedFlatList, ScrollHandler, Separator } from '$uikit'; -import { ns, formatAmount } from '$utils'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { jettonsActions, jettonsSelector } from '$store/jettons'; -import { Switch } from 'react-native'; -import { JettonBalanceModel, JettonVerification } from '$store/models'; -import { useBottomTabBarHeight } from '$hooks/useBottomTabBarHeight'; -import { useJettonBalancesLegacy } from '$hooks/useJettonBalancesLegacy'; -import { t } from '@tonkeeper/shared/i18n'; - -export const JettonsList: FC = () => { - const { excludedJettons } = useSelector(jettonsSelector); - const { bottom: bottomInset } = useSafeAreaInsets(); - const tabBarHeight = useBottomTabBarHeight(); - const dispatch = useDispatch(); - - const onSwitchExcludedJetton = useCallback( - (jettonAddress: string, value: boolean) => () => - dispatch(jettonsActions.switchExcludedJetton({ jetton: jettonAddress, value })), - [dispatch], - ); - - const data = useJettonBalancesLegacy(true); - - function renderJetton({ - item: jetton, - index, - }: { - item: JettonBalanceModel; - index: number; - }) { - const isWhitelisted = jetton.verification === JettonVerification.WHITELIST; - const isEnabled = - (isWhitelisted && !excludedJettons[jetton.jettonAddress]) || - excludedJettons[jetton.jettonAddress] === false; - - return ( - - - - {jetton.metadata.name} - - {formatAmount(jetton.balance, jetton.metadata.decimals)}{' '} - {jetton.metadata.symbol} - - - - - ); - } - - return ( - - - 0 ? tabBarHeight : bottomInset), - paddingHorizontal: ns(16), - paddingTop: ns(16), - }} - ItemSeparatorComponent={Separator} - data={data} - renderItem={renderJetton} - /> - - - ); -}; diff --git a/packages/mobile/src/core/ManageTokens/ManageTokens.tsx b/packages/mobile/src/core/ManageTokens/ManageTokens.tsx index c9c66bf79..d4d589e35 100644 --- a/packages/mobile/src/core/ManageTokens/ManageTokens.tsx +++ b/packages/mobile/src/core/ManageTokens/ManageTokens.tsx @@ -1,40 +1,26 @@ -import React, { FC, useCallback, useState } from 'react'; +import React, { FC, useCallback, useMemo, useState } from 'react'; -import { Icon, Screen, Spacer, SText, View, List, Button } from '$uikit'; +import { Screen, Spacer, SText, View, Button } from '$uikit'; +import { List } from '@tonkeeper/uikit'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { JettonBalanceModel } from '$store/models'; import { Tabs } from '../../tabs/Wallet/components/Tabs'; import { Steezy } from '$styles'; import { FlashList } from '@shopify/flash-list'; import { t } from '@tonkeeper/shared/i18n'; import { ListSeparator } from '$uikit/List/ListSeparator'; import { StyleSheet } from 'react-native'; -import { TouchableOpacity } from 'react-native-gesture-handler'; import { ContentType, Content } from '$core/ManageTokens/ManageTokens.types'; import { useJettonData } from '$core/ManageTokens/hooks/useJettonData'; import { useNftData } from '$core/ManageTokens/hooks/useNftData'; -import { ScaleDecorator } from '$uikit/DraggableFlashList'; -import { NestableDraggableFlatList } from '$uikit/DraggableFlashList/components/NestableDraggableFlatList'; -import { NestableScrollContainer } from '$uikit/DraggableFlashList/components/NestableScrollContainer'; -import { Haptics } from '$utils'; -import { useTokenApprovalStore } from '$store/zustand/tokenApproval/useTokenApprovalStore'; import Animated, { useAnimatedScrollHandler, useSharedValue, } from 'react-native-reanimated'; import { useParams } from '$navigation/imperative'; -import { Address } from '@tonkeeper/shared/Address'; - +import { useInscriptionData } from '$core/ManageTokens/hooks/useInscriptionData'; const AnimatedFlashList = Animated.createAnimatedComponent(FlashList); -export function reorderJettons(newOrder: JettonBalanceModel[]) { - return newOrder.map((jettonBalance) => { - const rawAddress = Address.parse(jettonBalance.jettonAddress).toRaw(); - return rawAddress; - }); -} - const FLashListItem = ({ item, renderDragButton, @@ -72,6 +58,7 @@ const FLashListItem = ({ return ( { - const handleDrag = useCallback(() => { - drag?.(); - Haptics.impactMedium(); - }, [drag]); - - const renderDragButton = useCallback(() => { - return ( - - - - ); - }, [handleDrag, isActive]); - - return ( - - - - ); -}; - export const ManageTokens: FC = () => { const params = useParams<{ initialTab?: string }>(); const { bottom: bottomInset } = useSafeAreaInsets(); const [tab, setTab] = useState(params?.initialTab || 'tokens'); const jettonData = useJettonData(); const nftData = useNftData(); - const hasWatchedCollectiblesTab = useTokenApprovalStore( - (state) => state.hasWatchedCollectiblesTab, - ); - const setHasWatchedCollectiblesTab = useTokenApprovalStore( - (state) => state.actions.setHasWatchedCollectiblesTab, - ); + const inscriptionData = useInscriptionData(); const scrollY = useSharedValue(0); const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { @@ -128,135 +89,81 @@ export const ManageTokens: FC = () => { }, }); - const withCollectibleDot = React.useMemo(() => { - return !hasWatchedCollectiblesTab; - }, [hasWatchedCollectiblesTab]); + const tabsContent = useMemo(() => { + return [ + { + label: t('wallet.tonkens_tab_lable'), + id: 'tokens', + items: jettonData, + }, + { + label: t('wallet.nft_tab_lable'), + id: 'collectibles', + items: nftData, + }, + { + label: t('wallet.inscriptions_tab_label'), + id: 'inscriptions', + items: inscriptionData, + }, + ].filter((content) => content.items.length); + }, [inscriptionData, jettonData, nftData]); - const renderJettonList = useCallback(() => { - return ( - - ); - // TODO: draggable flashlist - return ( - - {jettonData.pending.length > 0 && ( - <> - - {t('approval.pending')} - - item?.id} - contentContainerStyle={StyleSheet.flatten([styles.flashList.static])} - data={jettonData.pending} - renderItem={DraggableFLashListItem} - /> - - - )} - {jettonData.enabled.length > 0 && ( - <> - - {t('approval.accepted')} - - item?.id} - contentContainerStyle={StyleSheet.flatten([styles.flashList.static])} - data={jettonData.enabled} - renderItem={DraggableFLashListItem} - /> - - - )} - {jettonData.disabled.length > 0 && ( - <> - - {t('approval.declined')} - - item?.id} - contentContainerStyle={StyleSheet.flatten([styles.flashList.static])} - data={jettonData.disabled} - renderItem={DraggableFLashListItem} - /> - - - )} - - ); - }, [bottomInset, jettonData, scrollHandler]); + const renderList = useCallback( + (data) => { + return ( + + ); + }, + [bottomInset, scrollHandler], + ); const renderTabs = useCallback(() => { return ( - - - + + { - setTab(value); - if (value === 'collectibles') { - setHasWatchedCollectiblesTab(true); - } - }} + onChange={({ value }) => setTab(value)} value={tab} - items={[ - { label: t('wallet.tonkens_tab_lable'), value: 'tokens' }, - { - label: t('wallet.nft_tab_lable'), - value: 'collectibles', - withDot: withCollectibleDot, - }, - ]} + items={tabsContent.map((content) => ({ + label: content.label, + value: content.id, + }))} /> - {renderJettonList()} - - item?.id} - estimatedItemSize={76} - contentContainerStyle={StyleSheet.flatten([ - styles.flashList.static, - { paddingBottom: bottomInset }, - ])} - onScroll={scrollHandler} - scrollEventThrottle={16} - data={nftData} - renderItem={FLashListItem} - /> - + {tabsContent.map((content, idx) => ( + + {renderList(content.items)} + + ))} ); - }, [ - bottomInset, - nftData, - renderJettonList, - scrollHandler, - scrollY, - setHasWatchedCollectiblesTab, - tab, - withCollectibleDot, - ]); + }, [renderList, scrollY, tab, tabsContent]); - if (nftData.length && jettonData.length) { + if (tabsContent.length > 1) { return renderTabs(); } else { return ( @@ -270,7 +177,7 @@ export const ManageTokens: FC = () => { styles.flashList.static, { paddingBottom: bottomInset }, ])} - data={nftData.length ? nftData : jettonData} + data={tabsContent[0].items} /> ); @@ -282,6 +189,9 @@ const styles = Steezy.create(({ safeArea, corners, colors }) => ({ position: 'relative', paddingTop: safeArea.top, }, + flex: { + flex: 1, + }, flashList: { paddingHorizontal: 16, }, @@ -312,4 +222,10 @@ const styles = Steezy.create(({ safeArea, corners, colors }) => ({ alignItems: 'center', justifyContent: 'space-between', }, + tabsContainer: { paddingBottom: 16 }, + tabsItem: { paddingTop: 16, paddingBottom: 8 }, + tabsIndicator: { bottom: 0 }, + contentContainer: { + paddingLeft: 65, + }, })); diff --git a/packages/mobile/src/core/ManageTokens/ManageTokens.types.ts b/packages/mobile/src/core/ManageTokens/ManageTokens.types.ts index 825f1177e..bb419df0d 100644 --- a/packages/mobile/src/core/ManageTokens/ManageTokens.types.ts +++ b/packages/mobile/src/core/ManageTokens/ManageTokens.types.ts @@ -1,6 +1,5 @@ import { SpacerSizes } from '$uikit'; -import { ListSeparatorProps } from '$uikit/List/ListSeparator'; -import { ListItemProps } from '$uikit/List/ListItem'; +import { ListItemProps } from '@tonkeeper/uikit/src/components/List/ListItem'; import { ReactNode } from 'react'; export enum ContentType { @@ -30,7 +29,6 @@ export type ShowAllButtonItem = { }; export type CellItem = { - separatorVariant?: ListSeparatorProps['variant']; type: ContentType.Cell; imageStyle?: ListItemProps['pictureStyle']; chevronColor?: ListItemProps['chevronColor']; diff --git a/packages/mobile/src/core/ManageTokens/hooks/useInscriptionData.tsx b/packages/mobile/src/core/ManageTokens/hooks/useInscriptionData.tsx new file mode 100644 index 000000000..ca0e8afef --- /dev/null +++ b/packages/mobile/src/core/ManageTokens/hooks/useInscriptionData.tsx @@ -0,0 +1,127 @@ +import React, { useMemo, useState } from 'react'; +import { t } from '@tonkeeper/shared/i18n'; +import { formatter } from '$utils/formatter'; +import { openApproveTokenModal } from '$core/ModalContainer/ApproveToken/ApproveToken'; +import { tk } from '$wallet'; +import { + TokenApprovalStatus, + TokenApprovalType, +} from '$store/zustand/tokenApproval/types'; +import { ListButton } from '$uikit'; +import { CellItem, Content, ContentType } from '$core/ManageTokens/ManageTokens.types'; +import { InscriptionBalance } from '@tonkeeper/core/src/TonAPI'; +import { useInscriptionBalances } from '$hooks/useInscriptionBalances'; + +const baseInscriptionCellData = (inscription: InscriptionBalance) => ({ + type: ContentType.Cell, + id: `${inscription.ticker}_${inscription.type}`, + title: inscription.ticker, + subtitle: formatter.format( + formatter.fromNano(inscription.balance, inscription.decimals), + { + currency: inscription.ticker, + currencySeparator: 'wide', + }, + ), + onPress: () => + openApproveTokenModal({ + type: TokenApprovalType.Inscription, + tokenIdentifier: `${inscription.ticker}_${inscription.type}`, + name: inscription.ticker, + }), +}); + +export function useInscriptionData() { + const [isExtendedEnabled, setIsExtendedEnabled] = useState(false); + const [isExtendedDisabled, setIsExtendedDisabled] = useState(false); + const { enabled, disabled } = useInscriptionBalances(); + return useMemo(() => { + const content: Content[] = []; + + if (enabled.length) { + content.push({ + id: 'enabled_title', + type: ContentType.Title, + title: t('approval.accepted'), + }); + content.push( + ...enabled.slice(0, !isExtendedEnabled ? 4 : undefined).map( + (inscriptionBalance, index, array) => + ({ + ...baseInscriptionCellData(inscriptionBalance), + isFirst: index === 0, + leftContent: ( + + tk.wallet.tokenApproval.updateTokenStatus( + `${inscriptionBalance.ticker}_${inscriptionBalance.type}`, + TokenApprovalStatus.Declined, + TokenApprovalType.Inscription, + ) + } + /> + ), + isLast: index === array.length - 1, + } as CellItem), + ), + ); + if (!isExtendedEnabled && enabled.length > 4) { + content.push({ + id: 'show_accepted_inscriptionss', + onPress: () => setIsExtendedEnabled(true), + type: ContentType.ShowAllButton, + }); + } + content.push({ + id: 'accepted_spacer', + type: ContentType.Spacer, + bottom: 16, + }); + } + + if (disabled.length) { + content.push({ + id: 'disabled_title', + type: ContentType.Title, + title: t('approval.declined'), + }); + content.push( + ...disabled.slice(0, !isExtendedDisabled ? 4 : undefined).map( + (inscriptionBalance, index, array) => + ({ + ...baseInscriptionCellData(inscriptionBalance), + isFirst: index === 0, + isLast: index === array.length - 1, + leftContent: ( + + tk.wallet.tokenApproval.updateTokenStatus( + `${inscriptionBalance.ticker}_${inscriptionBalance.type}`, + TokenApprovalStatus.Approved, + TokenApprovalType.Inscription, + ) + } + /> + ), + } as CellItem), + ), + ); + if (!isExtendedDisabled && disabled.length > 4) { + content.push({ + id: 'show_disabled_inscriptions', + onPress: () => setIsExtendedDisabled(true), + type: ContentType.ShowAllButton, + }); + } + content.push({ + id: 'disabled_spacer', + type: ContentType.Spacer, + bottom: 16, + }); + } + + return content; + }, [disabled, enabled, isExtendedDisabled, isExtendedEnabled]); +} diff --git a/packages/mobile/src/core/ManageTokens/hooks/useJettonData.tsx b/packages/mobile/src/core/ManageTokens/hooks/useJettonData.tsx index c9bf5c5e2..13b32744f 100644 --- a/packages/mobile/src/core/ManageTokens/hooks/useJettonData.tsx +++ b/packages/mobile/src/core/ManageTokens/hooks/useJettonData.tsx @@ -2,17 +2,18 @@ import React, { useMemo, useState } from 'react'; import { t } from '@tonkeeper/shared/i18n'; import { formatter } from '$utils/formatter'; import { openApproveTokenModal } from '$core/ModalContainer/ApproveToken/ApproveToken'; +import { tk } from '$wallet'; import { TokenApprovalStatus, TokenApprovalType, } from '$store/zustand/tokenApproval/types'; import { ListButton, Spacer } from '$uikit'; import { CellItem, Content, ContentType } from '$core/ManageTokens/ManageTokens.types'; -import { useTokenApprovalStore } from '$store/zustand/tokenApproval/useTokenApprovalStore'; import { useJettonBalances } from '$hooks/useJettonBalances'; -import { config } from '@tonkeeper/shared/config'; +import { config } from '$config'; import { JettonVerification } from '$store/models'; import { Text } from '@tonkeeper/uikit'; +import { Address } from '@tonkeeper/core'; const baseJettonCellData = (jettonBalance) => ({ type: ContentType.Cell, @@ -34,7 +35,7 @@ const baseJettonCellData = (jettonBalance) => ({ onPress: () => openApproveTokenModal({ type: TokenApprovalType.Token, - tokenAddress: jettonBalance.jettonAddress, + tokenIdentifier: Address.parse(jettonBalance.jettonAddress).toRaw(), verification: jettonBalance.verification, image: jettonBalance.metadata?.image, name: jettonBalance.metadata?.name, @@ -44,9 +45,6 @@ const baseJettonCellData = (jettonBalance) => ({ export function useJettonData() { const [isExtendedEnabled, setIsExtendedEnabled] = useState(false); const [isExtendedDisabled, setIsExtendedDisabled] = useState(false); - const updateTokenStatus = useTokenApprovalStore( - (state) => state.actions.updateTokenStatus, - ); const { enabled, disabled } = useJettonBalances(); const data = useMemo(() => { const content: Content[] = []; @@ -68,8 +66,8 @@ export function useJettonData() { - updateTokenStatus( - jettonBalance.jettonAddress, + tk.wallet.tokenApproval.updateTokenStatus( + Address.parse(jettonBalance.jettonAddress).toRaw(), TokenApprovalStatus.Declined, TokenApprovalType.Token, ) @@ -114,8 +112,8 @@ export function useJettonData() { - updateTokenStatus( - jettonBalance.jettonAddress, + tk.wallet.tokenApproval.updateTokenStatus( + Address.parse(jettonBalance.jettonAddress).toRaw(), TokenApprovalStatus.Approved, TokenApprovalType.Token, ) @@ -142,7 +140,7 @@ export function useJettonData() { } return content; - }, [disabled, enabled, isExtendedDisabled, isExtendedEnabled, updateTokenStatus]); + }, [disabled, enabled, isExtendedDisabled, isExtendedEnabled]); return data; } diff --git a/packages/mobile/src/core/ManageTokens/hooks/useNftData.tsx b/packages/mobile/src/core/ManageTokens/hooks/useNftData.tsx index 92f25b36d..abaa5ea98 100644 --- a/packages/mobile/src/core/ManageTokens/hooks/useNftData.tsx +++ b/packages/mobile/src/core/ManageTokens/hooks/useNftData.tsx @@ -4,15 +4,16 @@ import { ImageType, openApproveTokenModal, } from '$core/ModalContainer/ApproveToken/ApproveToken'; -import { - TokenApprovalStatus, - TokenApprovalType, -} from '$store/zustand/tokenApproval/types'; import { ListButton, Spacer } from '$uikit'; import { CellItem, Content, ContentType } from '$core/ManageTokens/ManageTokens.types'; -import { useTokenApprovalStore } from '$store/zustand/tokenApproval/useTokenApprovalStore'; import { useApprovedNfts } from '$hooks/useApprovedNfts'; import { JettonVerification, NFTModel } from '$store/models'; +import { tk } from '$wallet'; +import { + TokenApprovalType, + TokenApprovalStatus, +} from '$wallet/managers/TokenApprovalManager'; +import { Address } from '@tonkeeper/core'; const baseNftCellData = (nft: NFTModel) => ({ type: ContentType.Cell, @@ -33,7 +34,7 @@ const baseNftCellData = (nft: NFTModel) => ({ verification: nft.isApproved ? JettonVerification.WHITELIST : JettonVerification.NONE, - tokenAddress: nft.collection?.address || nft.address, + tokenIdentifier: Address.parse(nft.collection?.address || nft.address).toRaw(), image: nft.content.image.baseUrl, name: nft.collection?.name, }), @@ -60,9 +61,6 @@ export function groupByCollection( export function useNftData() { const [isExtendedEnabled, setIsExtendedEnabled] = useState(false); const [isExtendedDisabled, setIsExtendedDisabled] = useState(false); - const updateTokenStatus = useTokenApprovalStore( - (state) => state.actions.updateTokenStatus, - ); const { enabled, disabled } = useApprovedNfts(); return useMemo(() => { const content: Content[] = []; @@ -86,8 +84,8 @@ export function useNftData() { - updateTokenStatus( - nft.collection?.address || nft.address, + tk.wallet.tokenApproval.updateTokenStatus( + Address.parse(nft.collection?.address || nft.address).toRaw(), TokenApprovalStatus.Declined, nft.collection?.address ? TokenApprovalType.Collection @@ -137,8 +135,8 @@ export function useNftData() { - updateTokenStatus( - nft.collection?.address || nft.address, + tk.wallet.tokenApproval.updateTokenStatus( + Address.parse(nft.collection?.address || nft.address).toRaw(), TokenApprovalStatus.Approved, nft.collection?.address ? TokenApprovalType.Collection @@ -167,5 +165,5 @@ export function useNftData() { } return content; - }, [disabled, enabled, isExtendedDisabled, isExtendedEnabled, updateTokenStatus]); + }, [disabled, enabled, isExtendedDisabled, isExtendedEnabled]); } diff --git a/packages/mobile/src/core/Migration/Card/Card.interface.ts b/packages/mobile/src/core/Migration/Card/Card.interface.ts deleted file mode 100644 index 321bd9a48..000000000 --- a/packages/mobile/src/core/Migration/Card/Card.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface CardProps { - mode: 'old' | 'new'; - address: string; - amount: string; - startValue: string; -} diff --git a/packages/mobile/src/core/Migration/Card/Card.style.ts b/packages/mobile/src/core/Migration/Card/Card.style.ts deleted file mode 100644 index 1607801c9..000000000 --- a/packages/mobile/src/core/Migration/Card/Card.style.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Animated from 'react-native-reanimated'; - -import styled from '$styled'; -import { nfs, ns } from '$utils'; - -export const Wrap = styled(Animated.View)` - padding: ${ns(16)}px ${ns(16)}px ${ns(16)}px ${ns(16)}px; - justify-content: space-between; - background: ${({ theme }) => theme.colors.backgroundSecondary}; - border-radius: ${({ theme }) => ns(theme.radius.normal)}px; - width: ${ns(136)}px; - height: ${ns(136)}px; - position: absolute; - left: ${ns(57)}px; - top: ${ns(28)}px; -`; - -export const AmountWrap = styled.View``; diff --git a/packages/mobile/src/core/Migration/Card/Card.tsx b/packages/mobile/src/core/Migration/Card/Card.tsx deleted file mode 100644 index bbff7a063..000000000 --- a/packages/mobile/src/core/Migration/Card/Card.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { FC, useEffect, useMemo } from 'react'; -import { - Easing, - interpolate, - useAnimatedStyle, - useSharedValue, - withDelay, - withTiming, -} from 'react-native-reanimated'; - -import * as S from './Card.style'; -import { CardProps } from '$core/Migration/Card/Card.interface'; -import { useTheme } from '$hooks/useTheme'; -import { ns } from '$utils'; -import { useCounter } from '$core/Migration/Card/useCounter'; -import { CryptoCurrencies, Decimals } from '$shared/constants'; -import { formatCryptoCurrency } from '$utils/currency'; -import { Text } from '$uikit'; -import { t } from '@tonkeeper/shared/i18n'; - -const PositionOffsetHorizontal = ns(57); -const PositionOffsetVertical = ns(28); - -const positionDelay = 1000; -const positionDuration = 200; - -const amountDelay = 100; -const amountDuration = 800; - -export const Card: FC = (props) => { - const { mode, address, amount, startValue } = props; - const theme = useTheme(); - - const positionValue = useSharedValue(0); - const amountValue = useCounter( - positionDelay + positionDuration + amountDelay, - amountDuration, - amount, - mode === 'old' ? 'decr' : 'incr', - startValue, - ); - - useEffect(() => { - positionValue.value = withDelay( - positionDelay, - withTiming(1, { - duration: positionDuration, - easing: Easing.inOut(Easing.ease), - }), - ); - }, []); - - const label = useMemo(() => { - return t(mode === 'old' ? 'migration_old_wallet' : 'migration_new_wallet'); - }, [mode, t]); - - const backgroundColor = useMemo(() => { - return theme.colors[mode === 'old' ? 'backgroundSecondary' : 'backgroundTertiary']; - }, [mode, theme]); - - const positionStyle = useAnimatedStyle(() => { - const y = mode === 'old' ? -PositionOffsetVertical : PositionOffsetVertical; - const x = mode === 'old' ? -PositionOffsetHorizontal : PositionOffsetHorizontal; - return { - transform: [ - { - translateY: interpolate(positionValue.value, [0, 1], [0, y]), - }, - { - translateX: interpolate(positionValue.value, [0, 1], [0, x]), - }, - ], - }; - }); - - return ( - - - {label} - - - - {address} - - - {formatCryptoCurrency( - amountValue, - CryptoCurrencies.Ton, - Decimals[CryptoCurrencies.Ton], - )} - - - - ); -}; diff --git a/packages/mobile/src/core/Migration/Card/useCounter.ts b/packages/mobile/src/core/Migration/Card/useCounter.ts deleted file mode 100644 index d2f761d3f..000000000 --- a/packages/mobile/src/core/Migration/Card/useCounter.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import BigNumber from 'bignumber.js'; - -export function useCounter( - delayMs: number, - duration: number, - amount: string, - mode: 'incr' | 'decr', - startValue: string = '0', -) { - const [isStarted, setStarted] = useState(false); - const [value, setValue] = useState(mode === 'decr' ? amount : startValue); - const timer = useRef(0); - const interval = useRef(0); - - const onUpdate = useCallback( - (step) => () => { - setValue((oldValue) => { - let newVal = new BigNumber(oldValue); - if (mode === 'incr') { - newVal = newVal.plus(step); - - const maxVal = new BigNumber(amount).plus(startValue); - if (newVal.isGreaterThan(maxVal)) { - newVal = maxVal; - clearInterval(interval.current); - } - } else { - newVal = newVal.minus(step); - - if (newVal.isLessThan(0)) { - newVal = new BigNumber(0); - clearInterval(interval.current); - } - } - - return newVal.toString(); - }); - }, - [amount, mode, setValue], - ); - - useEffect(() => { - if (isStarted) { - const stepDuration = 50; - const step = new BigNumber(amount).dividedBy(duration / stepDuration); - interval.current = setInterval(onUpdate(step), stepDuration); - } - - return () => { - clearInterval(interval.current); - }; - }, [isStarted]); - - useEffect(() => { - timer.current = setTimeout(() => { - setStarted(true); - }, delayMs); - - return () => { - clearTimeout(timer.current); - }; - }, []); - - return value; -} diff --git a/packages/mobile/src/core/Migration/Migration.interface.ts b/packages/mobile/src/core/Migration/Migration.interface.ts deleted file mode 100644 index 82df79e0d..000000000 --- a/packages/mobile/src/core/Migration/Migration.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { RouteProp } from '@react-navigation/native'; - -import { AppStackRouteNames } from '$navigation'; -import { AppStackParamList } from '$navigation/AppStack'; - -export interface MigrationProps { - route: RouteProp; -} diff --git a/packages/mobile/src/core/Migration/Migration.style.ts b/packages/mobile/src/core/Migration/Migration.style.ts deleted file mode 100644 index 2c4be934b..000000000 --- a/packages/mobile/src/core/Migration/Migration.style.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { SafeAreaView } from 'react-native-safe-area-context'; -import Animated from 'react-native-reanimated'; - -import styled from '$styled'; -import { nfs, ns } from '$utils'; - -export const Wrap = styled(SafeAreaView)` - flex: 1; - padding: ${ns(32)}px; -`; - -export const Header = styled.View` - flex: 0 0 auto; - padding-bottom: ${ns(16)}px; -`; - -export const CaptionWrapper = styled.View` - margin-top: ${ns(4)}px; -`; - -export const Footer = styled.View` - flex: 0 0 auto; - padding-top: ${ns(36)}px; -`; - -export const CardsWrap = styled.View` - flex: 1; - align-items: center; - justify-content: center; -`; - -export const Cards = styled.View` - flex: 0 0 auto; - width: ${ns(250)}px; - height: ${ns(190)}px; - position: relative; -`; - -export const Step = styled(Animated.View)` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -`; - -export const StateWrap = styled(SafeAreaView)` - flex: 1; - align-items: center; - justify-content: center; - padding: ${ns(32)}px; -`; - -export const StateIcon = styled(Animated.View)` - width: ${ns(84)}px; - height: ${ns(84)}px; -`; - -export const StateTitleWrapper = styled.View` - margin-top: ${ns(16)}px; -`; diff --git a/packages/mobile/src/core/Migration/Migration.tsx b/packages/mobile/src/core/Migration/Migration.tsx deleted file mode 100644 index 80b690249..000000000 --- a/packages/mobile/src/core/Migration/Migration.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { - cancelAnimation, - Easing, - interpolate, - useAnimatedStyle, - useSharedValue, - withDelay, - withRepeat, - withTiming, -} from 'react-native-reanimated'; - -import * as S from './Migration.style'; -import { Button, Icon, Text } from '$uikit'; -import { deviceWidth, ns, toLocaleNumber, triggerNotificationSuccess } from '$utils'; -import { Card } from '$core/Migration/Card/Card'; - -import { MigrationProps } from './Migration.interface'; -import { walletActions } from '$store/wallet'; -import { CryptoCurrencies } from '$shared/constants'; -import { useTheme } from '$hooks/useTheme'; -import { useTokenPrice } from '$hooks/useTokenPrice'; -import { formatFiatCurrencyAmount } from '$utils/currency'; -import { mainSelector } from '$store/main'; -import { goBack } from '$navigation/imperative'; -import { t } from '@tonkeeper/shared/i18n'; - -export const Migration: FC = ({ route }) => { - const { - oldAddress, - newAddress, - migrationInProgress, - oldBalance, - newBalance, - isTransfer, - fromVersion, - } = route.params; - - const dispatch = useDispatch(); - const theme = useTheme(); - const [step, setStep] = useState(migrationInProgress ? 1 : 0); - const [cardsScale, setCardsScale] = useState(1); - const { fiatCurrency } = useSelector(mainSelector); - - const iconAnimation = useSharedValue(0); - const slideAnimation = useSharedValue(migrationInProgress ? 1 : 0); - const feePrice = useTokenPrice(CryptoCurrencies.Ton, '0.01'); - - useEffect(() => { - if (migrationInProgress) { - dispatch( - walletActions.waitMigration({ - onDone: () => setStep(2), - onFail: () => setStep(0), - }), - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - slideAnimation.value = withTiming(step, { - duration: 350, - easing: Easing.inOut(Easing.ease), - }); - - if (step === 1) { - iconAnimation.value = withRepeat( - withDelay( - 600, - withTiming(1, { - duration: 1400, - }), - ), - Infinity, - false, - ); - } else { - cancelAnimation(iconAnimation); - iconAnimation.value = 0; - } - - let successTimer: any; - if (step === 2) { - triggerNotificationSuccess(); - successTimer = setTimeout(() => { - goBack(); - }, 3000); - } - - return () => clearTimeout(successTimer); - }, [step]); - - const handleUpgrade = useCallback(() => { - setStep(1); - dispatch( - walletActions.migrate({ - fromVersion, - oldAddress, - newAddress, - onDone: () => setStep(2), - onFail: () => setStep(0), - }), - ); - }, [dispatch, newAddress, oldAddress]); - - const handleSkip = useCallback(() => { - goBack(); - }, []); - - // Scale cards for small devices - const handleCardsLayout = useCallback(({ nativeEvent }) => { - const height = nativeEvent.layout.height; - const minHeight = ns(192); - - const scaleFactor = Math.min(height / minHeight, 1); - setCardsScale(scaleFactor); - }, []); - - const iconStyle = useAnimatedStyle(() => ({ - transform: [ - { - rotate: `${interpolate(iconAnimation.value, [0, 1], [0, 180])}deg`, - }, - ], - })); - - const step1Style = useAnimatedStyle(() => ({ - opacity: interpolate(Math.min(slideAnimation.value, 1), [0, 1], [1, 0]), - transform: [ - { - translateX: interpolate( - Math.min(slideAnimation.value, 1), - [0, 1], - [0, -deviceWidth], - ), - }, - ], - })); - - const step2Style = useAnimatedStyle(() => ({ - opacity: interpolate(Math.min(slideAnimation.value, 2), [0, 1, 2], [0, 1, 0]), - transform: [ - { - translateX: interpolate( - Math.min(slideAnimation.value, 2), - [0, 1, 2], - [deviceWidth, 0, -deviceWidth], - ), - }, - ], - })); - - const step3Style = useAnimatedStyle(() => { - const value = Math.min(Math.max(1, slideAnimation.value), 2); - return { - opacity: interpolate(value, [1, 2], [0, 1]), - transform: [ - { - translateX: interpolate(value, [1, 2], [deviceWidth, 0]), - }, - ], - }; - }); - - return ( - <> - - - - - {t(isTransfer ? 'transfer_from_old_wallet_title' : 'migration_title')} - - - - {t(isTransfer ? 'transfer_from_old_wallet_caption' : 'migration_caption')} - - - - - - - - - - - {t('migration_fee_info', { - tonFee: toLocaleNumber('0.01'), - fiatFee: `${formatFiatCurrencyAmount( - feePrice.totalFiat.toFixed(2), - fiatCurrency, - )}`, - })} - - - - - - - - - - - - - - - {t( - isTransfer - ? 'transfer_from_old_wallet_in_progress' - : 'migration_in_progress', - )} - - - - - - - - - - - - - ); -}; diff --git a/packages/mobile/src/core/ModalContainer/AddressMismatch/AddressMismatch.tsx b/packages/mobile/src/core/ModalContainer/AddressMismatch/AddressMismatch.tsx index 8b743aa09..f5867a7f6 100644 --- a/packages/mobile/src/core/ModalContainer/AddressMismatch/AddressMismatch.tsx +++ b/packages/mobile/src/core/ModalContainer/AddressMismatch/AddressMismatch.tsx @@ -1,75 +1,61 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { t } from '@tonkeeper/shared/i18n'; import { Modal } from '@tonkeeper/uikit'; import { Button, Icon, Text } from '$uikit'; import * as S from './AddressMismatch.style'; -import { useWallet } from '$hooks/useWallet'; import { useNavigation, SheetActions } from '@tonkeeper/router'; import { delay } from '$utils'; -import { walletActions } from '$store/wallet'; -import { useDispatch } from 'react-redux'; -import { SelectableVersion } from '$shared/constants'; import { push } from '$navigation/imperative'; import { Address } from '@tonkeeper/shared/Address'; +import { useWallets } from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; export const AddressMismatchModal = memo<{ source: string; onSwitchAddress: () => void }>( (props) => { - const [allVersions, setAllVersions] = useState( - null, - ); - const wallet = useWallet(); const nav = useNavigation(); - const dispatch = useDispatch(); - - useEffect(() => { - wallet.ton.getAllAddresses().then((allAddresses) => setAllVersions(allAddresses)); - }, [wallet.ton]); + const wallets = useWallets(); - const foundVersion = useMemo(() => { - if (!allVersions) { - return false; - } - let found = Object.entries(allVersions).find(([_, address]) => - Address.compare(address, props.source), - ); - if (!found) { - return false; - } - return found[0]; - }, [allVersions, props.source]); + const foundWallet = useMemo(() => { + return wallets.find((wallet) => { + return Address.compare(wallet.address.ton.raw, props.source); + }); + }, [props.source, wallets]); const handleCloseModal = useCallback(() => nav.goBack(), [nav]); - const handleSwitchVersion = useCallback(async () => { - if (!foundVersion) { + const handleSwitchWallet = useCallback(async () => { + if (!foundWallet) { return; } nav.goBack(); - dispatch(walletActions.switchVersion(foundVersion as SelectableVersion)); + tk.switchWallet(foundWallet.identifier); await delay(100); props.onSwitchAddress(); - }, [dispatch, foundVersion, nav, props]); - - // Wait to get all versions - if (!allVersions) { - return null; - } + }, [foundWallet, nav, props]); return ( - + - {foundVersion - ? t('txActions.signRaw.addressMismatch.wrongVersion.title') + {foundWallet + ? t('txActions.signRaw.addressMismatch.switchWallet.title', { + value: `${ + foundWallet.config.emoji + } ${foundWallet.config.name.replaceAll(' ', ' ')}`, + }) : t('txActions.signRaw.addressMismatch.wrongWallet.title')} - {foundVersion - ? t('txActions.signRaw.addressMismatch.wrongVersion.description', { - version: foundVersion, + {foundWallet + ? t('txActions.signRaw.addressMismatch.switchWallet.description', { + version: foundWallet, }) : t('txActions.signRaw.addressMismatch.wrongWallet.description', { address: Address.parse(props.source).toShort(), @@ -79,18 +65,18 @@ export const AddressMismatchModal = memo<{ source: string; onSwitchAddress: () = - {foundVersion ? ( + {foundWallet ? ( ) : null} diff --git a/packages/mobile/src/core/ModalContainer/AppearanceModal/AppearanceModal.tsx b/packages/mobile/src/core/ModalContainer/AppearanceModal/AppearanceModal.tsx index 5d550295b..212519c0c 100644 --- a/packages/mobile/src/core/ModalContainer/AppearanceModal/AppearanceModal.tsx +++ b/packages/mobile/src/core/ModalContainer/AppearanceModal/AppearanceModal.tsx @@ -1,8 +1,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDimensions } from '$hooks/useDimensions'; -import { mainActions, accentSelector, accentTonIconSelector } from '$store/main'; +import { accentSelector } from '$store/main'; import { NFTModel, TonDiamondMetadata } from '$store/models'; -import { nftsSelector } from '$store/nfts'; import { AccentKey, AccentModel, @@ -10,10 +9,9 @@ import { AppearanceAccents, getAccentIdByDiamondsNFT, } from '$styled'; -import { checkIsTonDiamondsNFT, ns } from '$utils'; +import { checkIsTonDiamondsNFT, delay, ns } from '$utils'; import { ListRenderItem } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; -import { useDispatch, useSelector } from 'react-redux'; import { AccentItem, ACCENT_ITEM_WIDTH } from './AccentItem/AccentItem'; import { AppearanceModalProps } from './AppearanceModal.interface'; import * as S from './AppearanceModal.style'; @@ -22,7 +20,11 @@ import { t } from '@tonkeeper/shared/i18n'; import { Modal, View } from '@tonkeeper/uikit'; import { SheetActions, useNavigation } from '@tonkeeper/router'; import { push } from '$navigation/imperative'; -import { openMarketplaces } from '../Marketplaces/Marketplaces'; +import { useNftsState, useWallet } from '@tonkeeper/shared/hooks'; +import { mapNewNftToOldNftData } from '$utils/mapNewNftToOldNftData'; +import { BrowserStackRouteNames, TabsStackRouteNames } from '$navigation'; +import { tk } from '$wallet'; +import { Address } from '@tonkeeper/shared/Address'; const AppearanceModal = memo((props) => { const { selectedAccentNFTAddress } = props; @@ -34,15 +36,15 @@ const AppearanceModal = memo((props) => { window: { width: windowWidth }, } = useDimensions(); - const dispatch = useDispatch(); - - const { myNfts } = useSelector(nftsSelector); - const currentAccent = useSelector(accentSelector); - const accentTonIcon = useSelector(accentTonIconSelector); + const { accountNfts, selectedDiamond } = useNftsState(); + const wallet = useWallet(); const diamondNFTs = useMemo( - () => Object.values(myNfts).filter(checkIsTonDiamondsNFT), - [myNfts], + () => + Object.values(accountNfts) + .map((item) => mapNewNftToOldNftData(item, wallet.address.ton.friendly)) + .filter(checkIsTonDiamondsNFT), + [accountNfts, wallet], ); const getNFTIcon = useCallback((nft: NFTModel) => { @@ -60,6 +62,7 @@ const AppearanceModal = memo((props) => { ...AppearanceAccents[getAccentIdByDiamondsNFT(nft)], available: true, nftIcon: getNFTIcon(nft), + nft, })); const otherAccents = Object.values(AppearanceAccents) @@ -97,11 +100,11 @@ const AppearanceModal = memo((props) => { } const index = accents.findIndex((item) => { - if (accentTonIcon) { - return item.nftIcon?.uri === accentTonIcon.uri; + if (selectedDiamond && item.nft) { + return Address.compare(item.nft.address, selectedDiamond.address); } - return item.id === currentAccent; + return false; }); return index !== -1 ? index : 0; @@ -130,17 +133,19 @@ const AppearanceModal = memo((props) => { : t('nft_open_in_marketplace'); const changeAccent = useCallback(() => { - dispatch(mainActions.setAccent(selectedAccent.id)); - dispatch(mainActions.setTonCustomIcon(selectedAccent?.nftIcon || null)); + tk.wallet.nfts.setSelectedDiamond(selectedAccent.nft?.address ?? null); nav.goBack(); - }, [dispatch, nav, selectedAccent.id, selectedAccent?.nftIcon]); + }, [nav, selectedAccent]); - const openDiamondsNFTCollection = useCallback(() => { + const openDiamondsNFTCollection = useCallback(async () => { if (selectedAccent) { - openMarketplaces({ accentKey: selectedAccent.id }); + nav.goBack(); + await delay(300); + nav.navigate(TabsStackRouteNames.BrowserStack); + nav.push(BrowserStackRouteNames.Category, { categoryId: 'nft' }); } - }, [selectedAccent]); + }, [nav, selectedAccent]); useEffect(() => { setTimeout(() => { diff --git a/packages/mobile/src/core/ModalContainer/ApproveToken/ApproveToken.tsx b/packages/mobile/src/core/ModalContainer/ApproveToken/ApproveToken.tsx index 3218e8d2e..151928b71 100644 --- a/packages/mobile/src/core/ModalContainer/ApproveToken/ApproveToken.tsx +++ b/packages/mobile/src/core/ModalContainer/ApproveToken/ApproveToken.tsx @@ -1,14 +1,8 @@ import { Modal } from '@tonkeeper/uikit'; import { SheetActions, useNavigation } from '@tonkeeper/router'; import React, { memo, useCallback, useMemo } from 'react'; -import { - TokenApprovalStatus, - TokenApprovalType, -} from '$store/zustand/tokenApproval/types'; -import { useTokenApprovalStore } from '$store/zustand/tokenApproval/useTokenApprovalStore'; -import { getTokenStatus } from '$store/zustand/tokenApproval/selectors'; import { JettonVerification } from '$store/models'; -import { Button, Icon, Spacer, View, List } from '$uikit'; +import { Button, Icon, List, Spacer, View } from '$uikit'; import { Steezy } from '$styles'; import { t } from '@tonkeeper/shared/i18n'; import { triggerImpactLight } from '$utils'; @@ -18,6 +12,12 @@ import Clipboard from '@react-native-community/clipboard'; import { TranslateOptions } from 'i18n-js'; import { push } from '$navigation/imperative'; +import { useTokenApproval } from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; +import { + TokenApprovalStatus, + TokenApprovalType, +} from '$wallet/managers/TokenApprovalManager'; import { Address } from '@tonkeeper/core'; export enum ImageType { @@ -25,7 +25,7 @@ export enum ImageType { SQUARE = 'square', } export interface ApproveTokenModalParams { - tokenAddress: string; + tokenIdentifier: string; type: TokenApprovalType; verification?: JettonVerification; imageType?: ImageType; @@ -34,26 +34,27 @@ export interface ApproveTokenModalParams { } export const ApproveToken = memo((props: ApproveTokenModalParams) => { const nav = useNavigation(); - const currentStatus = useTokenApprovalStore((state) => - getTokenStatus(state, props.tokenAddress), - ); - const updateTokenStatus = useTokenApprovalStore( - (state) => state.actions.updateTokenStatus, - ); + const currentStatus = useTokenApproval((state) => { + return state.tokens[props.tokenIdentifier]; + }); const handleUpdateStatus = useCallback( (approvalStatus: TokenApprovalStatus) => () => { - updateTokenStatus(props.tokenAddress, approvalStatus, props.type); + tk.wallet.tokenApproval.updateTokenStatus( + props.tokenIdentifier, + approvalStatus, + props.type, + ); nav.goBack(); }, - [nav, props.tokenAddress, props.type, updateTokenStatus], + [nav, props.tokenIdentifier, props.type], ); const handleCopyAddress = useCallback(() => { - Clipboard.setString(props.tokenAddress); + Clipboard.setString(props.tokenIdentifier); triggerImpactLight(); Toast.show(t('approval.token_copied')); - }, [props.tokenAddress]); + }, [props.tokenIdentifier]); const modalState = useMemo(() => { if ( @@ -71,10 +72,13 @@ export const ApproveToken = memo((props: ApproveTokenModalParams) => { }, [currentStatus, props.verification]); const translationPrefix = useMemo(() => { - if (props.type === TokenApprovalType.Token) { - return 'token'; - } else { - return 'collection'; + switch (props.type) { + case TokenApprovalType.Token: + return 'token'; + case TokenApprovalType.Collection: + return 'collection'; + case TokenApprovalType.Inscription: + return 'token'; } }, [props.type]); @@ -132,7 +136,11 @@ export const ApproveToken = memo((props: ApproveTokenModalParams) => { diff --git a/packages/mobile/src/core/ModalContainer/CreateSubscription/CreateSubscription.tsx b/packages/mobile/src/core/ModalContainer/CreateSubscription/CreateSubscription.tsx index ae01c9dd6..8f57058ef 100644 --- a/packages/mobile/src/core/ModalContainer/CreateSubscription/CreateSubscription.tsx +++ b/packages/mobile/src/core/ModalContainer/CreateSubscription/CreateSubscription.tsx @@ -15,7 +15,7 @@ import { triggerNotificationSuccess, } from '$utils'; import { subscriptionsActions } from '$store/subscriptions'; -import { CryptoCurrencies, Decimals, getServerConfig } from '$shared/constants'; +import { CryptoCurrencies, Decimals } from '$shared/constants'; import { formatCryptoCurrency } from '$utils/currency'; import { useWalletInfo } from '$hooks/useWalletInfo'; import { walletWalletSelector } from '$store/wallet'; @@ -27,6 +27,8 @@ import { t } from '@tonkeeper/shared/i18n'; import { push } from '$navigation/imperative'; import { SheetActions, useNavigation } from '@tonkeeper/router'; import { Modal, View } from '@tonkeeper/uikit'; +import { config } from '$config'; +import { tk } from '$wallet'; export const CreateSubscription: FC = ({ invoiceId = null, @@ -38,7 +40,7 @@ export const CreateSubscription: FC = ({ const nav = useNavigation(); const wallet = useSelector(walletWalletSelector); - const { amount: balance } = useWalletInfo(CryptoCurrencies.Ton); + const { amount: balance } = useWalletInfo(); const [isLoading, setLoading] = useState(!isEdit); const [failed, setFailed] = useState(0); @@ -93,7 +95,7 @@ export const CreateSubscription: FC = ({ }, [isSuccess]); const loadInfo = useCallback(() => { - const host = getServerConfig('subscriptionsHost'); + const host = config.get('subscriptionsHost', tk.wallet.isTestnet); network .get(`${host}/v1/subscribe/invoice/${invoiceId}`, { params: { diff --git a/packages/mobile/src/core/ModalContainer/ExchangeMethod/ExchangeMethod.tsx b/packages/mobile/src/core/ModalContainer/ExchangeMethod/ExchangeMethod.tsx index 2f90d3397..e12035aa0 100644 --- a/packages/mobile/src/core/ModalContainer/ExchangeMethod/ExchangeMethod.tsx +++ b/packages/mobile/src/core/ModalContainer/ExchangeMethod/ExchangeMethod.tsx @@ -9,7 +9,6 @@ import * as S from './ExchangeMethod.style'; import { openBuyFiat } from '$navigation'; import { openRequireWalletModal } from '$core/ModalContainer/RequireWallet/RequireWallet'; import { CryptoCurrencies } from '$shared/constants'; -import { walletSelector } from '$store/wallet'; import { CheckmarkItem } from '$uikit/CheckmarkItem'; import { t } from '@tonkeeper/shared/i18n'; import { ExchangeDB } from './ExchangeDB'; @@ -17,10 +16,11 @@ import { trackEvent } from '$utils/stats'; import { push } from '$navigation/imperative'; import { SheetActions, useNavigation } from '@tonkeeper/router'; import { Modal, View } from '@tonkeeper/uikit'; +import { useWallet } from '@tonkeeper/shared/hooks'; export const ExchangeMethod: FC = ({ methodId, onContinue }) => { const method = useExchangeMethodInfo(methodId); - const { wallet } = useSelector(walletSelector); + const wallet = useWallet(); const [isDontShow, setIsDontShow] = React.useState(false); const nav = useNavigation(); diff --git a/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx b/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx index 318febdfd..e4a2401d3 100644 --- a/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx +++ b/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx @@ -8,13 +8,15 @@ import * as S from './InsufficientFunds.style'; import { delay, fromNano } from '$utils'; import { debugLog } from '$utils/debugLog'; import BigNumber from 'bignumber.js'; -import { Tonapi } from '$libs/Tonapi'; import { store } from '$store'; import { formatter } from '$utils/formatter'; import { push } from '$navigation/imperative'; import { useBatteryBalance } from '@tonkeeper/shared/query/hooks/useBatteryBalance'; -import { config } from '@tonkeeper/shared/config'; +import { config } from '$config'; import { openRefillBatteryModal } from '@tonkeeper/shared/modals/RefillBatteryModal'; +import { tk } from '$wallet'; +import { Wallet } from '$wallet/Wallet'; +import { AmountFormatter } from '@tonkeeper/core'; export interface InsufficientFundsParams { /** @@ -160,11 +162,10 @@ export const InsufficientFundsModal = memo((props) => { ); }); -export async function checkIsInsufficient(amount: string | number) { +export async function checkIsInsufficient(amount: string | number, wallet: Wallet) { try { - const wallet = store.getState().wallet.wallet; - const address = await wallet.ton.getAddress(); - const { balance } = await Tonapi.getWalletInfo(address); + const balances = await wallet.balances.load(); + const balance = AmountFormatter.toNano(balances.ton); return { insufficient: new BigNumber(amount).gt(new BigNumber(balance)), balance }; } catch (e) { debugLog('[checkIsInsufficient]: error', e); diff --git a/packages/mobile/src/core/ModalContainer/LinkingDomainModal.tsx b/packages/mobile/src/core/ModalContainer/LinkingDomainModal.tsx index 30daabde6..2f6dce143 100644 --- a/packages/mobile/src/core/ModalContainer/LinkingDomainModal.tsx +++ b/packages/mobile/src/core/ModalContainer/LinkingDomainModal.tsx @@ -14,13 +14,12 @@ import { TouchableOpacity } from 'react-native'; import { store, Toast } from '$store'; import { Wallet } from 'blockchain'; -import { Tonapi } from '$libs/Tonapi'; import { Modal } from '@tonkeeper/uikit'; import { push } from '$navigation/imperative'; import { SheetActions } from '@tonkeeper/router'; import { openReplaceDomainAddress } from './NFTOperations/ReplaceDomainAddressModal'; import { Address } from '@tonkeeper/core'; -import { tonapi } from '@tonkeeper/shared/tonkeeper'; +import { tk } from '$wallet'; const TonWeb = require('tonweb'); @@ -62,7 +61,7 @@ export class LinkingDomainActions { public async calculateFee() { try { const boc = await this.createBoc(); - const feeInfo = await tonapi.wallet.emulateMessageToWallet({ boc }); + const feeInfo = await tk.wallet.tonapi.wallet.emulateMessageToWallet({ boc }); const feeNano = new BigNumber(feeInfo.event.extra).multipliedBy(-1); return truncateDecimal(Ton.fromNano(feeNano.toString()), 1); @@ -137,7 +136,7 @@ export const LinkingDomainModal: React.FC = ({ setIsDisabled(true); const boc = await linkingActions.createBoc(privateKey); - await tonapi.blockchain.sendBlockchainMessage({ boc }, { format: 'text' }); + await tk.wallet.tonapi.blockchain.sendBlockchainMessage({ boc }, { format: 'text' }); }); const handleReplace = React.useCallback(() => { diff --git a/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.interface.ts b/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.interface.ts deleted file mode 100644 index 93222f972..000000000 --- a/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface MarketplaceItemProps { - topRadius: boolean; - bottomRadius: boolean; - marketplaceUrl: string; - iconUrl: string; - description: string; - title: string; - internalId: string; -} diff --git a/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.style.ts b/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.style.ts deleted file mode 100644 index 78795a0dc..000000000 --- a/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.style.ts +++ /dev/null @@ -1,75 +0,0 @@ -import FastImage from 'react-native-fast-image'; - -import styled, { RADIUS } from '$styled'; -import { Highlight } from '$uikit'; -import { hNs, nfs, ns } from '$utils'; - -export const Wrap = styled.View` - position: relative; -`; - -const radius = (topRadius: boolean, bottomRadius: boolean) => { - return ` - ${ - topRadius - ? ` - border-top-left-radius: ${ns(RADIUS.normal)}px; - border-top-right-radius: ${ns(RADIUS.normal)}px; - ` - : '' - } - ${ - bottomRadius - ? ` - border-bottom-left-radius: ${ns(RADIUS.normal)}px; - border-bottom-right-radius: ${ns(RADIUS.normal)}px; - ` - : '' - } - `; -}; - -export const Card = styled(Highlight)<{ topRadius: boolean; bottomRadius: boolean }>` - overflow: hidden; - padding: ${hNs(16)}px ${ns(16)}px; - ${({ bottomRadius, topRadius }) => radius(topRadius, bottomRadius)} -`; - -export const CardIn = styled.View` - flex-direction: row; - align-items: center; - justify-content: space-between; -`; - -export const Divider = styled.View` - height: ${ns(0.5)}px; - background: ${({ theme }) => theme.colors.border}; - margin-left: ${ns(16)}px; -`; - -export const Icon = styled(FastImage).attrs({ - resizeMode: 'cover', - priority: FastImage.priority.high, -})` - width: ${ns(44)}px; - height: ${hNs(44)}px; - border-radius: ${ns(44 / 2)}px; - margin-right: ${ns(16)}px; -`; - -export const Contain = styled.View` - flex: 1; - margin-right: ${ns(24.5)}px; -`; - -export const IconContain = styled.View``; - -export const Badge = styled.View` - padding: ${hNs(4)}px ${ns(8)}px; - background: ${({ theme }) => theme.colors.accentPrimary}; - border-radius: ${ns(8)}px; - position: absolute; - top: ${hNs(16 + 8)}px; - right: ${ns(3)}px; - z-index: 3; -`; \ No newline at end of file diff --git a/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.tsx b/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.tsx deleted file mode 100644 index 097e6fc8b..000000000 --- a/packages/mobile/src/core/ModalContainer/Marketplaces/MarketplaceItem/MarketplaceItem.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { FC, useCallback } from 'react'; - -import { MarketplaceItemProps } from './MarketplaceItem.interface'; -import * as S from './MarketplaceItem.style'; -import { Icon, Text } from '$uikit'; -import { trackEvent } from '$utils/stats'; -import { openDAppBrowser } from '$navigation'; - -export const MarketplaceItem: FC = ({ - marketplaceUrl, - iconUrl, - title, - description, - topRadius, - bottomRadius, - internalId, -}) => { - const handlePress = useCallback(async () => { - openDAppBrowser(marketplaceUrl); - trackEvent('marketplace_open', { internal_id: internalId }); - }, [marketplaceUrl, internalId]); - - return ( - - - - - - {title} - - {description} - - - - - - - - {!bottomRadius ? : null} - - ); -}; diff --git a/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.interface.ts b/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.interface.ts deleted file mode 100644 index b38d2fd35..000000000 --- a/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AccentKey } from '$styled'; - -export interface MarketplacesModalProps { - accentKey?: AccentKey; -} diff --git a/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.style.ts b/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.style.ts deleted file mode 100644 index 45aab8d5f..000000000 --- a/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.style.ts +++ /dev/null @@ -1,14 +0,0 @@ -import styled from '$styled'; -import { hNs, ns } from '$utils'; - -export const LoaderWrap = styled.View` - flex: 1; - align-items: center; - justify-content: center; -`; - -export const Contain = styled.View` - background: ${({ theme }) => theme.colors.backgroundSecondary}; - margin: 0 ${hNs(16)}px; - border-radius: ${({ theme }) => ns(theme.radius.normal)}px; -`; diff --git a/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.tsx b/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.tsx deleted file mode 100644 index cbe8eea1d..000000000 --- a/packages/mobile/src/core/ModalContainer/Marketplaces/Marketplaces.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { FC, useMemo } from 'react'; -import { useSelector } from 'react-redux'; - -import { Loader } from '$uikit'; -import * as S from './Marketplaces.style'; -import { MarketplaceItem } from './MarketplaceItem/MarketplaceItem'; -import { t } from '@tonkeeper/shared/i18n'; -import { nftsSelector } from '$store/nfts'; -import { getDiamondsCollectionMarketUrl } from '$utils'; -import { MarketplacesModalProps } from './Marketplaces.interface'; -import { Modal, View } from '@tonkeeper/uikit'; -import { push } from '$navigation/imperative'; -import { SheetActions } from '@tonkeeper/router'; - -export const Marketplaces: FC = (props) => { - const { accentKey } = props; - - const { isMarketplacesLoading, marketplaces: data } = useSelector(nftsSelector); - - const marketplaces = useMemo(() => { - if (accentKey) { - return data - .filter((item) => ['getgems', 'tonDiamonds'].includes(item.id)) - .map((market) => ({ - ...market, - marketplace_url: getDiamondsCollectionMarketUrl(market, accentKey), - })); - } - - return data; - }, [accentKey, data]); - - function renderContent() { - // don't show spinner if we have loaded marketplaces - if (!marketplaces.length && isMarketplacesLoading) { - return ( - - - - ); - } - - return ( - - {marketplaces.map((item, idx, arr) => ( - - ))} - - ); - } - - return ( - - - - {renderContent()} - - - ); -}; - -export function openMarketplaces(props?: MarketplacesModalProps) { - push('SheetsProvider', { - $$action: SheetActions.ADD, - component: Marketplaces, - params: props, - path: 'MARKETPLACES', - }); -} diff --git a/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx b/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx index 087ffa2a6..7342c3b02 100644 --- a/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx +++ b/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx @@ -5,38 +5,45 @@ import { useUnlockVault } from '../useUnlockVault'; import { calculateMessageTransferAmount, delay } from '$utils'; import { debugLog } from '$utils/debugLog'; import { t } from '@tonkeeper/shared/i18n'; -import { store, Toast } from '$store'; +import { Toast } from '$store'; import { List, Modal, Spacer, Steezy, Text, View } from '@tonkeeper/uikit'; import { push } from '$navigation/imperative'; -import { SheetActions } from '@tonkeeper/router'; +import { SheetActions, useNavigation } from '@tonkeeper/router'; import { checkIsInsufficient, openInsufficientFundsModal, } from '$core/ModalContainer/InsufficientFunds/InsufficientFunds'; import { TonConnectRemoteBridge } from '$tonconnect/TonConnectRemoteBridge'; import { formatter } from '$utils/formatter'; -import { tk, tonapi } from '@tonkeeper/shared/tonkeeper'; +import { tk } from '$wallet'; import { MessageConsequences } from '@tonkeeper/core/src/TonAPI'; import { - ActionAmountType, - ActionSource, - ActionType, - ActivityModel, Address, - AnyActionItem, ContractService, contractVersionsMap, TransactionService, } from '@tonkeeper/core'; import { ActionListItemByType } from '@tonkeeper/shared/components/ActivityList/ActionListItemByType'; -import { useSelector } from 'react-redux'; -import { fiatCurrencySelector } from '$store/main'; import { useGetTokenPrice } from '$hooks/useTokenPrice'; import { formatValue, getActionTitle } from '@tonkeeper/shared/utils/signRaw'; import { Buffer } from 'buffer'; import { trackEvent } from '$utils/stats'; import { Events, SendAnalyticsFrom } from '$store/models'; import { getWalletSeqno } from '@tonkeeper/shared/utils/wallet'; +import { useWalletCurrency } from '@tonkeeper/shared/hooks'; +import { + ActionAmountType, + ActionSource, + ActionType, + ActivityModel, + AnyActionItem, +} from '$wallet/models/ActivityModel'; +import { JettonTransferAction, NftItemTransferAction } from 'tonapi-sdk-js'; +import { + TokenDetailsParams, + TokenDetailsProps, +} from '../../../../components/TokenDetails/TokenDetails'; +import { ModalStackRouteNames } from '$navigation'; interface SignRawModalProps { consequences?: MessageConsequences; @@ -46,6 +53,7 @@ interface SignRawModalProps { onDismiss?: () => void; redirectToActivity?: boolean; isBattery?: boolean; + walletIdentifier: string; } export const SignRawModal = memo((props) => { @@ -57,15 +65,25 @@ export const SignRawModal = memo((props) => { consequences, isBattery, redirectToActivity, + walletIdentifier, } = props; - const { footerRef, onConfirm } = useNFTOperationState(options); + const wallet = useMemo(() => tk.wallets.get(walletIdentifier), [walletIdentifier]); + const nav = useNavigation(); + + if (!wallet) { + throw new Error('wallet is not found'); + } + + const { footerRef, onConfirm } = useNFTOperationState(options, wallet); const unlockVault = useUnlockVault(); - const fiatCurrency = useSelector(fiatCurrencySelector); + const fiatCurrency = useWalletCurrency(); const getTokenPrice = useGetTokenPrice(); + const handleOpenTokenDetails = (tokenDetailsParams: TokenDetailsParams) => () => + nav.navigate(ModalStackRouteNames.TokenDetails, tokenDetailsParams); const handleConfirm = onConfirm(async ({ startLoading }) => { - const vault = await unlockVault(); + const vault = await unlockVault(wallet.identifier); const privateKey = await vault.getTonPrivateKey(); startLoading(); @@ -77,12 +95,12 @@ export const SignRawModal = memo((props) => { ); const boc = TransactionService.createTransfer(contract, { messages: TransactionService.parseSignRawMessages(params.messages), - seqno: await getWalletSeqno(), + seqno: await getWalletSeqno(wallet), sendMode: 3, secretKey: Buffer.from(privateKey), }); - await tonapi.blockchain.sendBlockchainMessage( + await wallet.tonapi.blockchain.sendBlockchainMessage( { boc, }, @@ -105,30 +123,32 @@ export const SignRawModal = memo((props) => { const actions = useMemo(() => { if (consequences) { return ActivityModel.createActions({ - ownerAddress: tk.wallet.address.ton.raw, + ownerAddress: wallet.address.ton.raw, events: [consequences.event], source: ActionSource.Ton, }); } else { // convert messages to TonTransfer actions return params.messages.map((message) => { - return ActivityModel.createMockAction(tk.wallet.address.ton.raw, { + return ActivityModel.createMockAction(wallet.address.ton.raw, { type: ActionType.TonTransfer, payload: { amount: Number(message.amount), sender: { address: message.address, is_scam: false, + is_wallet: true, }, recipient: { address: message.address, is_scam: false, + is_wallet: true, }, }, }); }); } - }, [consequences]); + }, [consequences, params.messages, wallet]); const extra = useMemo(() => { if (consequences) { @@ -185,7 +205,25 @@ export const SignRawModal = memo((props) => { return ( - + 1 && ( + + + {t('confirmSendModal.wallet')} + + + {wallet.config.emoji} + + + {wallet.config.name} + + + ) + } + /> {actions.map((action) => ( @@ -194,7 +232,17 @@ export const SignRawModal = memo((props) => { value={formatValue(action)} subvalue={amountToFiat(action)} title={getActionTitle(action)} - disablePressable + disableNftPreview={true} + disablePressable={ + !( + (action.payload as any as JettonTransferAction)?.jetton || + (action.payload as any as NftItemTransferAction)?.nft + ) + } + onPress={handleOpenTokenDetails({ + jetton: (action.payload as any as JettonTransferAction)?.jetton, + nft: (action.payload as any as NftItemTransferAction)?.nft, + })} action={action} subtitle={ action.destination === 'in' @@ -242,8 +290,10 @@ export const openSignRawModal = async ( onDismiss?: () => void, isTonConnect?: boolean, redirectToActivity = true, + walletIdentifier?: string, ) => { - const wallet = store.getState().wallet.wallet; + const wallet = walletIdentifier ? tk.wallets.get(walletIdentifier) : tk.wallet; + if (!wallet) { return false; } @@ -256,9 +306,9 @@ export const openSignRawModal = async ( } const contract = ContractService.getWalletContract( - contractVersionsMap[wallet.ton.version], - Buffer.from(wallet.ton.vault.tonPublicKey), - wallet.ton.vault.workchain, + contractVersionsMap[wallet.config.version], + Buffer.from(wallet.pubkey, 'hex'), + wallet.config.workchain, ); let consequences: MessageConsequences | null = null; @@ -266,16 +316,16 @@ export const openSignRawModal = async ( try { const boc = TransactionService.createTransfer(contract, { messages: TransactionService.parseSignRawMessages(params.messages), - seqno: await getWalletSeqno(), + seqno: await getWalletSeqno(wallet), secretKey: Buffer.alloc(64), }); - consequences = await tonapi.wallet.emulateMessageToWallet({ + consequences = await wallet.tonapi.wallet.emulateMessageToWallet({ boc, }); if (!isBattery) { const totalAmount = calculateMessageTransferAmount(params.messages); - const checkResult = await checkIsInsufficient(totalAmount); + const checkResult = await checkIsInsufficient(totalAmount, wallet); if (checkResult.insufficient) { Toast.hide(); onDismiss?.(); @@ -308,6 +358,7 @@ export const openSignRawModal = async ( onDismiss, redirectToActivity, isBattery, + walletIdentifier: wallet.identifier, }, path: 'SignRaw', }); @@ -328,6 +379,10 @@ const styles = Steezy.create({ flexDirection: 'row', justifyContent: 'space-between', }, + subtitleContainer: { + flexDirection: 'row', + gap: 4, + }, withBatteryContainer: { paddingHorizontal: 32, }, diff --git a/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperationFooter.tsx b/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperationFooter.tsx index 0256971a5..936bab614 100644 --- a/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperationFooter.tsx +++ b/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperationFooter.tsx @@ -16,8 +16,9 @@ import { CanceledActionError, DismissedActionError, } from '$core/Send/steps/ConfirmStep/ActionErrors'; -import { tk } from '@tonkeeper/shared/tonkeeper'; +import { tk } from '$wallet'; import { TabsStackRouteNames } from '$navigation'; +import { Wallet } from '$wallet/Wallet'; enum States { INITIAL, @@ -31,8 +32,8 @@ type ConfirmFn = (options: { startLoading: () => void }) => Promise; // Wrapper action footer for TxRequest // TODO: Rename NFTOperation -> Action -export const useNFTOperationState = (txBody?: TxBodyOptions) => { - const { footerRef, onConfirm: invokeConfirm } = useActionFooter(); +export const useNFTOperationState = (txBody?: TxBodyOptions, wallet?: Wallet) => { + const { footerRef, onConfirm: invokeConfirm } = useActionFooter(wallet); const onConfirm = (confirm: ConfirmFn) => async () => { try { @@ -79,7 +80,7 @@ export const useNFTOperationState = (txBody?: TxBodyOptions) => { return { footerRef, onConfirm }; }; -export const useActionFooter = () => { +export const useActionFooter = (wallet?: Wallet) => { const ref = React.useRef(null); const onConfirm = (confirm: ConfirmFn) => async () => { @@ -90,7 +91,9 @@ export const useActionFooter = () => { }, }); - ref.current?.setState(States.SUCCESS); + ref.current?.setState(States.SUCCESS, () => { + (wallet ?? tk.wallet).activityList.reload(); + }); } catch (error) { if (error instanceof DismissedActionError) { ref.current?.setState(States.ERROR); @@ -119,7 +122,7 @@ export const useActionFooter = () => { }; type ActionFooterRef = { - setState: (state: States) => Promise; + setState: (state: States, onDone?: () => void) => Promise; setError: (msg: string) => void; reset: () => void; }; @@ -157,14 +160,14 @@ export const ActionFooter = React.forwardRef ); React.useImperativeHandle(ref, () => ({ - async setState(state) { + async setState(state, onDone) { setState(state); if (state === States.SUCCESS) { triggerNotificationSuccess(); await delay(1750); - tk.wallet.activityList.reload(); + onDone?.(); closeModal(true); props.responseOptions?.onDone?.(); diff --git a/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperations.ts b/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperations.ts index c2771dfbf..422469074 100644 --- a/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperations.ts +++ b/packages/mobile/src/core/ModalContainer/NFTOperations/NFTOperations.ts @@ -12,10 +12,9 @@ import { Address as AddressType } from 'tonweb/dist/types/utils/address'; import { Address } from '@ton/core'; import { t } from '@tonkeeper/shared/i18n'; import { Ton } from '$libs/Ton'; -import { getServerConfig } from '$shared/constants'; import { Configuration, NFTApi } from '@tonkeeper/core/src/legacy'; -import { sendBocWithBattery } from '@tonkeeper/shared/utils/blockchain'; -import { tonapi } from '@tonkeeper/shared/tonkeeper'; +import { tk } from '$wallet'; +import { config } from '$config'; const { NftItem } = TonWeb.token.nft; @@ -27,9 +26,9 @@ export class NFTOperations { private wallet: Wallet; private nftApi = new NFTApi( new Configuration({ - basePath: getServerConfig('tonapiV2Endpoint'), + basePath: config.get('tonapiV2Endpoint', tk.wallet.isTestnet), headers: { - Authorization: `Bearer ${getServerConfig('tonApiV2Key')}`, + Authorization: `Bearer ${config.get('tonApiV2Key', tk.wallet.isTestnet)}`, }, }), ); @@ -161,7 +160,7 @@ export class NFTOperations { const methods = await signRawMethods(); const queryMsg = await methods.getQuery(); const boc = Base64.encodeBytes(await queryMsg.toBoc(false)); - const feeInfo = await tonapi.wallet.emulateMessageToWallet({ boc }); + const feeInfo = await tk.wallet.tonapi.wallet.emulateMessageToWallet({ boc }); const fee = new BigNumber(feeInfo.event.extra).multipliedBy(-1).toNumber(); return truncateDecimal(Ton.fromNano(fee.toString()), 2, true); @@ -172,7 +171,10 @@ export class NFTOperations { const queryMsg = await methods.getQuery(); const boc = Base64.encodeBytes(await queryMsg.toBoc(false)); - await sendBocWithBattery(boc); + await tk.wallet.tonapi.blockchain.sendBlockchainMessage( + { boc }, + { format: 'text' }, + ); onDone?.(boc); }, @@ -232,7 +234,7 @@ export class NFTOperations { const methods = transfer(params); const queryMsg = await methods.getQuery(); const boc = Base64.encodeBytes(await queryMsg.toBoc(false)); - const feeInfo = await tonapi.wallet.emulateMessageToWallet({ boc }); + const feeInfo = await tk.wallet.tonapi.wallet.emulateMessageToWallet({ boc }); const fee = new BigNumber(feeInfo.event.extra).multipliedBy(-1).toNumber(); return truncateDecimal(Ton.fromNano(fee.toString()), 2, true); @@ -258,7 +260,7 @@ export class NFTOperations { try { const query = await transfer.getQuery(); const boc = Base64.encodeBytes(await query.toBoc(false)); - const feeInfo = await tonapi.wallet.emulateMessageToWallet({ boc }); + const feeInfo = await tk.wallet.tonapi.wallet.emulateMessageToWallet({ boc }); feeNano = new BigNumber(feeInfo.event.extra).multipliedBy(-1); } catch (e) { throw new NFTOperationError(t('send_fee_estimation_error')); @@ -275,7 +277,10 @@ export class NFTOperations { const queryMsg = await transfer.getQuery(); const boc = Base64.encodeBytes(await queryMsg.toBoc(false)); - await tonapi.blockchain.sendBlockchainMessage({ boc }, { format: 'text' }); + await tk.wallet.tonapi.blockchain.sendBlockchainMessage( + { boc }, + { format: 'text' }, + ); }, }; } diff --git a/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadCollectionMeta.ts b/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadCollectionMeta.ts index 45d2a1a57..057f649fb 100644 --- a/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadCollectionMeta.ts +++ b/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadCollectionMeta.ts @@ -1,7 +1,8 @@ import { debugLog } from '$utils/debugLog'; import React from 'react'; -import { getServerConfig } from '$shared/constants'; import { NFTApi, Configuration } from '@tonkeeper/core/src/legacy'; +import { tk } from '$wallet'; +import { config } from '$config'; export type NFTCollectionMeta = { name: string; @@ -18,12 +19,12 @@ export function useDownloadCollectionMeta(addr?: string) { const download = React.useCallback(async (address: string) => { try { - const endpoint = getServerConfig('tonapiV2Endpoint'); + const endpoint = config.get('tonapiV2Endpoint', tk.wallet.isTestnet); const nftApi = new NFTApi( new Configuration({ basePath: endpoint, headers: { - Authorization: `Bearer ${getServerConfig('tonApiV2Key')}`, + Authorization: `Bearer ${config.get('tonApiV2Key', tk.wallet.isTestnet)}`, }, }), ); diff --git a/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadNFT.ts b/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadNFT.ts index 5168f094e..2b0bc64fe 100644 --- a/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadNFT.ts +++ b/packages/mobile/src/core/ModalContainer/NFTOperations/useDownloadNFT.ts @@ -1,8 +1,9 @@ import { debugLog } from '$utils/debugLog'; import axios from 'axios'; import React from 'react'; -import { getServerConfig } from '$shared/constants'; import { proxyMedia } from '$utils/proxyMedia'; +import { config } from '$config'; +import { tk } from '$wallet'; export type NFTItemMeta = { name: string; @@ -17,7 +18,7 @@ export function useDownloadNFT(addr?: string) { const download = React.useCallback(async (address: string) => { try { - const endpoint = getServerConfig('tonapiV2Endpoint'); + const endpoint = config.get('tonapiV2Endpoint', tk.wallet.isTestnet); const response: any = await axios.post( `${endpoint}/v2/nfts/_bulk`, @@ -26,7 +27,7 @@ export function useDownloadNFT(addr?: string) { }, { headers: { - Authorization: `Bearer ${getServerConfig('tonApiV2Key')}`, + Authorization: `Bearer ${config.get('tonApiV2Key', tk.wallet.isTestnet)}`, }, }, ); diff --git a/packages/mobile/src/core/ModalContainer/NFTOperations/useUnlockVault.ts b/packages/mobile/src/core/ModalContainer/NFTOperations/useUnlockVault.ts index 6603e43bf..38001d536 100644 --- a/packages/mobile/src/core/ModalContainer/NFTOperations/useUnlockVault.ts +++ b/packages/mobile/src/core/ModalContainer/NFTOperations/useUnlockVault.ts @@ -6,14 +6,20 @@ import { UnlockedVault } from 'blockchain/vault'; export const useUnlockVault = () => { const dispatch = useDispatch(); - const unlockVault = React.useCallback(async () => { - return new Promise((resolve, reject) => { - dispatch(walletActions.walletGetUnlockedVault({ - onDone: (vault) => resolve(vault), - onFail: (err) => reject(err) - })); - }); - }, []); + const unlockVault = React.useCallback( + async (walletIdentifier?: string) => { + return new Promise((resolve, reject) => { + dispatch( + walletActions.walletGetUnlockedVault({ + onDone: (vault) => resolve(vault), + onFail: (err) => reject(err), + walletIdentifier, + }), + ); + }); + }, + [dispatch], + ); return unlockVault; -}; \ No newline at end of file +}; diff --git a/packages/mobile/src/core/ModalContainer/NewConfirmSending/NewConfirmSending.tsx b/packages/mobile/src/core/ModalContainer/NewConfirmSending/NewConfirmSending.tsx index 4b0acd090..10a9f7604 100644 --- a/packages/mobile/src/core/ModalContainer/NewConfirmSending/NewConfirmSending.tsx +++ b/packages/mobile/src/core/ModalContainer/NewConfirmSending/NewConfirmSending.tsx @@ -1,5 +1,5 @@ import React, { FC, useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { Alert } from 'react-native'; import BigNumber from 'bignumber.js'; @@ -7,9 +7,8 @@ import { ConfirmSendingProps } from './ConfirmSending.interface'; import * as S from './ConfirmSending.style'; import { useExchangeMethodInfo } from '$hooks/useExchangeMethodInfo'; import { CryptoCurrencies, Decimals } from '$shared/constants'; -import { walletActions, walletSelector } from '$store/wallet'; +import { walletActions } from '$store/wallet'; import { formatCryptoCurrency } from '$utils/currency'; -import { getTokenConfig } from '$shared/dynamicConfig'; import { useCurrencyToSend } from '$hooks/useCurrencyToSend'; import { Modal } from '@tonkeeper/uikit'; import { @@ -18,7 +17,7 @@ import { } from '../NFTOperations/NFTOperationFooter'; import { Separator } from '$uikit'; import { t } from '@tonkeeper/shared/i18n'; -import { TokenType } from '$core/Send/Send.interface'; +import { useBalancesState, useWallet } from '@tonkeeper/shared/hooks'; export const NewConfirmSending: FC = (props) => { const { currency, address, amount, comment, fee, tokenType, methodId } = props; @@ -29,7 +28,8 @@ export const NewConfirmSending: FC = (props) => { const { footerRef, onConfirm } = useNFTOperationState(); - const { balances, wallet } = useSelector(walletSelector); + const wallet = useWallet(); + const balances = useBalancesState(); const { decimals, jettonWalletAddress, currencyTitle } = useCurrencyToSend( currency, @@ -61,8 +61,8 @@ export const NewConfirmSending: FC = (props) => { if ( currency === CryptoCurrencies.Ton && wallet && - wallet.ton.isLockup() && - new BigNumber(balances[currency]).isLessThan(amountWithFee) + wallet.isLockup && + new BigNumber(balances.ton).isLessThan(amountWithFee) ) { Alert.alert(t('send_lockup_warning_title'), t('send_lockup_warning_caption'), [ { @@ -80,16 +80,7 @@ export const NewConfirmSending: FC = (props) => { } }, [amount, fee, currency, wallet, balances, doSend]); - const feeCurrency = useMemo(() => { - const tokenConfig = getTokenConfig(currency); - if (tokenConfig && tokenConfig.blockchain === 'ethereum') { - return CryptoCurrencies.Eth; - } else if (tokenType === TokenType.Jetton) { - return CryptoCurrencies.Ton; - } else { - return currency; - } - }, [currency, tokenType]); + const feeCurrency = CryptoCurrencies.Ton; const feeValue = React.useMemo(() => { if (fee === '0') { diff --git a/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/ReminderEnableNotificationsModal.styles.ts b/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/ReminderEnableNotificationsModal.styles.ts deleted file mode 100644 index 8ce7db68a..000000000 --- a/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/ReminderEnableNotificationsModal.styles.ts +++ /dev/null @@ -1,23 +0,0 @@ -import styled from '$styled'; -import { nfs, ns } from '$utils'; - -export const Wrap = styled.View` - margin-top: ${ns(32)}px; - padding-horizontal: ${ns(16)}px; - align-items: center; - justify-content: center; -`; - -export const Content = styled.View` - margin-bottom: ${ns(32)}px; -`; - -export const IconWrap = styled.View` - margin-bottom: ${ns(16)}px; - align-items: center; - justify-content: center; -`; - -export const Footer = styled.View` - padding-horizontal: ${ns(16)}px; -`; diff --git a/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/ReminderEnableNotificationsModal.tsx b/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/ReminderEnableNotificationsModal.tsx deleted file mode 100644 index 6b6177ab7..000000000 --- a/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/ReminderEnableNotificationsModal.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { t } from '@tonkeeper/shared/i18n'; -import { Button, Icon, Text } from '$uikit'; -import * as S from './ReminderEnableNotificationsModal.styles'; -import { useNotifications } from '$hooks/useNotifications'; -import { - removeReminderNotifications, - saveDontShowReminderNotifications, - saveReminderNotifications, - shouldOpenReminderNotifications, -} from '$utils/messaging'; -import { SheetActions, useNavigation } from '@tonkeeper/router'; -import { Modal, View } from '@tonkeeper/uikit'; -import { push } from '$navigation/imperative'; - -export const ReminderEnableNotificationsModal = () => { - const nav = useNavigation(); - const notifications = useNotifications(); - - React.useEffect(() => { - saveDontShowReminderNotifications(); - }, []); - - const handleEnable = React.useCallback(async () => { - const isSubscribe = await notifications.subscribe(); - if (isSubscribe) { - removeReminderNotifications(); - nav.goBack(); - } - }, []); - - const handleLater = React.useCallback(async () => { - saveReminderNotifications(); - nav.goBack(); - }, []); - - return ( - - - - - - - - - - - {t('reminder_notifications_title')} - - - {t('reminder_notifications_caption')} - - - - - - - - - - - ); -}; - -export async function openReminderEnableNotificationsModal() { - const shouldOpen = await shouldOpenReminderNotifications(); - if (shouldOpen) { - push('SheetsProvider', { - $$action: SheetActions.ADD, - component: ReminderEnableNotificationsModal, - params: {}, - path: 'MARKETPLACES', - }); - } -} diff --git a/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/index.ts b/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/index.ts deleted file mode 100644 index 378289712..000000000 --- a/packages/mobile/src/core/ModalContainer/ReminderEnableNotificationsModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ReminderEnableNotificationsModal'; diff --git a/packages/mobile/src/core/ModalContainer/RequireWallet/RequireWallet.tsx b/packages/mobile/src/core/ModalContainer/RequireWallet/RequireWallet.tsx index 47fd7888c..4b9a91581 100644 --- a/packages/mobile/src/core/ModalContainer/RequireWallet/RequireWallet.tsx +++ b/packages/mobile/src/core/ModalContainer/RequireWallet/RequireWallet.tsx @@ -1,94 +1,11 @@ -import React, { FC, useCallback, useEffect, useRef } from 'react'; -import { useDispatch } from 'react-redux'; -import LottieView from 'lottie-react-native'; - -import * as S from './RequireWallet.style'; -import { Text, Button, Modal, View } from '@tonkeeper/uikit'; - -import { openImportWallet } from '$navigation/helper'; -import { walletActions } from '$store/wallet'; -import { SheetActions, useNavigation } from '@tonkeeper/router'; -import { t } from '@tonkeeper/shared/i18n'; -import { openCreateWallet } from '$core/CreateWallet/CreateWallet'; -import { push } from '$navigation/imperative'; - -export const RequireWallet: FC = () => { - const iconRef = useRef(null); - const nav = useNavigation(); - const dispatch = useDispatch(); - const destination = useRef(null); - - useEffect(() => { - dispatch(walletActions.clearGeneratedVault()); - - const timer = setTimeout(() => { - iconRef.current?.play(); - }, 400); - - return () => clearTimeout(timer); - }, [dispatch]); - - const handleClose = useCallback(() => { - if (destination.current === 'Create') { - openCreateWallet(); - } else if (destination.current === 'Import') { - openImportWallet(); - } - }, []); - - return ( - - - - - - - - - {t('require_create_wallet_modal_title')} - - - - - {t('require_create_wallet_modal_caption')} - - - - - - )} - {isOnSale ? ( - - - {isDNS ? t('dns_on_sale_text') : t('nft_on_sale_text')} - - - ) : null} - {(isDNS || isTG) && ( - + {nft.ownerAddress && ( + + )} + {isOnSale ? ( + + + {isDNS ? t('dns_on_sale_text') : t('nft_on_sale_text')} + + + ) : null} + {(isDNS || isTG) && ( + + )} + {isDNS && ( + + )} + {nft.marketplaceURL && !flags.disable_nft_markets ? ( + + ) : null} + - )} - {isDNS && ( - - )} - {nft.marketplaceURL && !flags.disable_nft_markets ? ( - - ) : null} - - + + ) : null} {!hiddenAmounts && }
{ - const address = new TonWeb.utils.Address(nftItem.address).toString(true, true, true); - const ownerAddress = nftItem.owner?.address - ? Address.parse(nftItem.owner.address, { - bounceable: !getFlag('address_style_nobounce'), - }).toFriendly() - : ''; - const name = - typeof nftItem.metadata?.name === 'string' - ? nftItem.metadata.name.trim() - : nftItem.metadata?.name; - - const baseUrl = (nftItem.previews && - nftItem.previews.find((preview) => preview.resolution === '500x500')!.url)!; - - return { - ...nftItem, - ownerAddressToDisplay: nftItem.sale ? walletFriendlyAddress : undefined, - isApproved: !!nftItem.approved_by?.length ?? false, - internalId: `${CryptoCurrencies.Ton}_${address}`, - currency: CryptoCurrencies.Ton, - provider: 'TonProvider', - content: { - image: { - baseUrl, - }, - }, - description: nftItem.metadata?.description, - marketplaceURL: nftItem.metadata?.marketplace && nftItem.metadata?.external_url, - attributes: nftItem.metadata?.attributes, - address, - name, - ownerAddress, - collection: nftItem.collection, - }; -}; diff --git a/packages/mobile/src/core/NFT/ProgrammableButtons/ProgrammableButtons.tsx b/packages/mobile/src/core/NFT/ProgrammableButtons/ProgrammableButtons.tsx index 92fc8b597..57d581dfc 100644 --- a/packages/mobile/src/core/NFT/ProgrammableButtons/ProgrammableButtons.tsx +++ b/packages/mobile/src/core/NFT/ProgrammableButtons/ProgrammableButtons.tsx @@ -12,9 +12,9 @@ import { createTonProof } from '$utils/proof'; import { useSelector } from 'react-redux'; import { walletWalletSelector } from '$store/wallet'; import { useUnlockVault } from '$core/ModalContainer/NFTOperations/useUnlockVault'; -import { isTestnetSelector } from '$store/main'; import { getDomainFromURL } from '$utils'; import { Address } from '@tonkeeper/core'; +import { tk } from '$wallet'; export interface ProgrammableButton { label?: string; @@ -32,7 +32,6 @@ export interface ProgrammableButtonsProps { const ProgrammableButtonsComponent = (props: ProgrammableButtonsProps) => { const wallet = useSelector(walletWalletSelector); const unlockVault = useUnlockVault(); - const isTestnet = useSelector(isTestnetSelector); const buttons = useMemo(() => { if (!props.buttons || !isArray(props.buttons)) { @@ -47,7 +46,7 @@ const ProgrammableButtonsComponent = (props: ProgrammableButtonsProps) => { try { const nftAddress = Address.parse(props.nftAddress).toRaw(); const vault = await unlockVault(); - const address = await vault.getTonAddress(isTestnet); + const address = await vault.getTonAddress(tk.wallet.isTestnet); let walletStateInit = ''; if (wallet) { const tonWallet = wallet.vault.tonWallet; @@ -83,7 +82,7 @@ const ProgrammableButtonsComponent = (props: ProgrammableButtonsProps) => { console.log(e); } }, - [isTestnet, props.nftAddress, unlockVault, wallet], + [props.nftAddress, unlockVault, wallet], ); const handleOpenLink = useCallback( diff --git a/packages/mobile/src/core/NFT/RenewDomainButton.tsx b/packages/mobile/src/core/NFT/RenewDomainButton.tsx index a642da418..9b15e3815 100644 --- a/packages/mobile/src/core/NFT/RenewDomainButton.tsx +++ b/packages/mobile/src/core/NFT/RenewDomainButton.tsx @@ -12,9 +12,10 @@ import { Ton } from '$libs/Ton'; import TonWeb from 'tonweb'; import { openAddressMismatchModal } from '$core/ModalContainer/AddressMismatch/AddressMismatch'; -import { useWallet } from '$hooks/useWallet'; import { Base64 } from '$utils'; import { Address } from '@tonkeeper/core'; +import { useWallet } from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; export type RenewDomainButtonRef = { renewUpdated: () => void; @@ -40,7 +41,7 @@ export const RenewDomainButton = forwardRef { - if (!wallet || !wallet.address?.rawAddress) { + if (!wallet) { return; } @@ -54,7 +55,7 @@ export const RenewDomainButton = forwardRef { - if (!wallet || !wallet.address?.rawAddress) { + if (!wallet) { return; } - if (!Address.compare(wallet.address.rawAddress, ownerAddress)) { + if (!Address.compare(tk.wallet.address.ton.raw, ownerAddress)) { return openAddressMismatchModal(openRenew, ownerAddress!); } else { openRenew(); diff --git a/packages/mobile/src/core/NFTSend/NFTSend.tsx b/packages/mobile/src/core/NFTSend/NFTSend.tsx index 5b9bbfa9b..d7e44eaf7 100644 --- a/packages/mobile/src/core/NFTSend/NFTSend.tsx +++ b/packages/mobile/src/core/NFTSend/NFTSend.tsx @@ -17,7 +17,6 @@ import { t } from '@tonkeeper/shared/i18n'; import { AddressStep } from '$core/Send/steps/AddressStep/AddressStep'; import { NFTSendSteps } from '$core/NFTSend/types'; import { ConfirmStep } from '$core/NFTSend/steps/ConfirmStep/ConfirmStep'; -import { useNFT } from '$hooks/useNFT'; import { BASE_FORWARD_AMOUNT, ContractService, @@ -26,7 +25,6 @@ import { ONE_TON, TransactionService, } from '@tonkeeper/core'; -import { tk, tonapi } from '@tonkeeper/shared/tonkeeper'; import { getWalletSeqno, setBalanceForEmulation } from '@tonkeeper/shared/utils/wallet'; import { Buffer } from 'buffer'; import { MessageConsequences } from '@tonkeeper/core/src/TonAPI'; @@ -36,7 +34,6 @@ import { Ton } from '$libs/Ton'; import { delay } from '$utils'; import { Toast } from '$store'; import axios from 'axios'; -import { useWallet } from '$hooks/useWallet'; import { useUnlockVault } from '$core/ModalContainer/NFTOperations/useUnlockVault'; import { emulateWithBattery, @@ -51,7 +48,9 @@ import { Keyboard } from 'react-native'; import nacl from 'tweetnacl'; import { useInstance } from '$hooks/useInstance'; import { AccountsApi, Configuration } from '@tonkeeper/core/src/legacy'; -import { getServerConfig } from '$shared/constants'; +import { useWallet } from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; +import { config } from '$config'; interface Props { route: RouteProp; @@ -82,7 +81,10 @@ export const NFTSend: FC = (props) => { ), ); - const nft = useNFT({ currency: 'ton', address: nftAddress }); + const nft = useMemo( + () => wallet.nfts.getCachedByAddress(nftAddress), + [nftAddress, wallet], + ); const scrollTop = useDerivedValue( () => stepsScrollTop.value[currentStep.id] || 0, ); @@ -124,7 +126,7 @@ export const NFTSend: FC = (props) => { tempKeyPair.publicKey, tempKeyPair.publicKey, tempKeyPair.secretKey, - tk.wallet.address.ton.raw, + wallet.address.ton.raw, ); } @@ -133,9 +135,8 @@ export const NFTSend: FC = (props) => { to: nftAddress, value: ONE_TON, body: ContractService.createNftTransferBody({ - queryId: Date.now(), newOwnerAddress: recipient!.address, - excessesAddress: tk.wallet.address.ton.raw, + excessesAddress: wallet.address.ton.raw, forwardBody: commentValue, }), bounce: true, @@ -143,9 +144,9 @@ export const NFTSend: FC = (props) => { ]; const contract = ContractService.getWalletContract( - contractVersionsMap[wallet.ton.version ?? 'v4R2'], - Buffer.from(await wallet.ton.getTonPublicKey()), - wallet.ton.workchain, + contractVersionsMap[wallet.config.version ?? 'v4R2'], + Buffer.from(wallet.pubkey, 'hex'), + wallet.config.workchain, ); const boc = TransactionService.createTransfer(contract, { @@ -177,13 +178,13 @@ export const NFTSend: FC = (props) => { } finally { setPreparing(false); } - }, [comment, isCommentEncrypted, nftAddress, recipient, wallet.ton]); + }, [comment, isCommentEncrypted, nftAddress, recipient, wallet]); const accountsApi = useInstance(() => { const tonApiConfiguration = new Configuration({ - basePath: getServerConfig('tonapiV2Endpoint'), + basePath: config.get('tonapiV2Endpoint', tk.wallet.isTestnet), headers: { - Authorization: `Bearer ${getServerConfig('tonApiV2Key')}`, + Authorization: `Bearer ${config.get('tonApiV2Key', tk.wallet.isTestnet)}`, }, }); @@ -243,15 +244,15 @@ export const NFTSend: FC = (props) => { if (isCommentEncrypted && comment.length) { const secretKey = await vault.getTonPrivateKey(); const recipientPubKey = ( - await tonapi.accounts.getAccountPublicKey(recipient!.address) + await tk.wallet.tonapi.accounts.getAccountPublicKey(recipient!.address) ).public_key; commentValue = await encryptMessageComment( comment, - wallet.vault.tonPublicKey, + vault.tonPublicKey, Buffer.from(recipientPubKey!, 'hex'), secretKey, - tk.wallet.address.ton.raw, + wallet.address.ton.raw, ); } @@ -259,7 +260,7 @@ export const NFTSend: FC = (props) => { ? BASE_FORWARD_AMOUNT : BigInt(Math.abs(consequences?.event.extra!)) + BASE_FORWARD_AMOUNT; - const checkResult = await checkIsInsufficient(totalAmount.toString()); + const checkResult = await checkIsInsufficient(totalAmount.toString(), tk.wallet); if (!isBattery && checkResult.insufficient) { openInsufficientFundsModal({ totalAmount: totalAmount.toString(), @@ -270,16 +271,15 @@ export const NFTSend: FC = (props) => { throw new CanceledActionError(); } - const excessesAccount = isBattery && (await tk.wallet.battery.getExcessesAccount()); + const excessesAccount = isBattery && (await wallet.battery.getExcessesAccount()); const nftTransferMessages = [ internal({ to: nftAddress, value: totalAmount, body: ContractService.createNftTransferBody({ - queryId: Date.now(), newOwnerAddress: recipient!.address, - excessesAddress: excessesAccount || tk.wallet.address.ton.raw, + excessesAddress: excessesAccount || wallet.address.ton.raw, forwardBody: commentValue, }), bounce: true, @@ -287,8 +287,8 @@ export const NFTSend: FC = (props) => { ]; const contract = ContractService.getWalletContract( - contractVersionsMap[wallet.ton.version ?? 'v4R2'], - Buffer.from(await wallet.ton.getTonPublicKey()), + contractVersionsMap[wallet.config.version ?? 'v4R2'], + Buffer.from(wallet.pubkey, 'hex'), vault.workchain, ); @@ -312,13 +312,15 @@ export const NFTSend: FC = (props) => { isCommentEncrypted, nftAddress, recipient, - recipientAccountInfo?.publicKey, total.isRefund, unlockVault, - wallet.ton, - wallet.vault.tonPublicKey, + wallet, ]); + if (!nft) { + return null; + } + return ( <> = (props) => { total={total} nftCollection={nft.collection?.name} nftName={nft.name} - nftIcon={nft.content.image.baseUrl} + nftIcon={nft.image.medium!} stepsScrollTop={stepsScrollTop} isPreparing={isPreparing} sendTx={sendTx} diff --git a/packages/mobile/src/core/NFTSend/steps/ConfirmStep/ConfirmStep.tsx b/packages/mobile/src/core/NFTSend/steps/ConfirmStep/ConfirmStep.tsx index 8ccaa4e3f..ba0a1e898 100644 --- a/packages/mobile/src/core/NFTSend/steps/ConfirmStep/ConfirmStep.tsx +++ b/packages/mobile/src/core/NFTSend/steps/ConfirmStep/ConfirmStep.tsx @@ -20,6 +20,7 @@ import { Address } from '@tonkeeper/core'; import { truncateDecimal } from '$utils'; import { BatteryState } from '@tonkeeper/shared/utils/battery'; import { useBatteryState } from '@tonkeeper/shared/query/hooks/useBatteryState'; +import { tk } from '$wallet'; interface Props extends StepComponentProps { recipient: SendRecipient | null; @@ -100,6 +101,19 @@ const ConfirmStepComponent: FC = (props) => { + {tk.wallets.size > 1 && ( + <> + + {t('send_screen_steps.comfirm.wallet')} + + + {tk.wallet.config.emoji} {tk.wallet.config.name} + + + + + + )} {recipientName ? ( {t('confirm_sending_recipient')} diff --git a/packages/mobile/src/core/NFTs/NFTs.interface.ts b/packages/mobile/src/core/NFTs/NFTs.interface.ts deleted file mode 100644 index 436a0dc10..000000000 --- a/packages/mobile/src/core/NFTs/NFTs.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { RouteProp } from '@react-navigation/native'; -import { TabsStackRouteNames } from '$navigation'; -import { TabStackParamList } from '$navigation/MainStack/TabStack/TabStack.interface'; - -export interface NFTsProps { - route: RouteProp; -} diff --git a/packages/mobile/src/core/NFTs/NFTs.style.ts b/packages/mobile/src/core/NFTs/NFTs.style.ts deleted file mode 100644 index 836e98bfe..000000000 --- a/packages/mobile/src/core/NFTs/NFTs.style.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IsTablet } from '$shared/constants'; -import styled, { css } from '$styled'; -import { ns } from '$utils'; - -export const Wrap = styled.View` - flex: 1; -`; - -export const RightButtonIconWrap = styled.View` - margin-left: ${ns(-4)}px; - margin-right: ${ns(4)}px; -`; - -export const RightButtonContainer = styled.View` - ${() => - IsTablet && - css` - margin-right: ${ns(16)}px; - `} -`; diff --git a/packages/mobile/src/core/NFTs/NFTs.tsx b/packages/mobile/src/core/NFTs/NFTs.tsx deleted file mode 100644 index 44414a5c7..000000000 --- a/packages/mobile/src/core/NFTs/NFTs.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { FC, useCallback, useMemo } from 'react'; - -import * as S from './NFTs.style'; -import { Button, ScrollHandler, AnimatedFlatList } from '$uikit'; -import { useTheme } from '$hooks/useTheme'; -import { RefreshControl } from 'react-native'; -import { MarketplaceBanner } from '$core/NFTs/MarketplaceBanner/MarketplaceBanner'; -import { hNs, ns } from '$utils'; -import { IsTablet, LargeNavBarHeight } from '$shared/constants'; -import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; -import { NFTItem } from '$core/NFTs/NFTItem/NFTItem'; -import { useDispatch, useSelector } from 'react-redux'; -import { nftsActions, nftsSelector } from '$store/nfts'; -import { useIsFocused } from '@react-navigation/native'; -import { openMarketplaces } from '$navigation'; -import { NUM_OF_COLUMNS } from '$core/NFTs/NFTItem/NFTItem.style'; -import { useFlags } from '$utils/flags'; -import { t } from '@tonkeeper/shared/i18n'; - -export const NFTs: FC = () => { - const flags = useFlags(['disable_nft_markets']); - - const theme = useTheme(); - const tabBarHeight = useBottomTabBarHeight(); - - const { myNfts, isLoading, canLoadMore } = useSelector(nftsSelector); - const dispatch = useDispatch(); - const isFocused = useIsFocused(); - - const data = useMemo(() => Object.values(myNfts), [myNfts]); - - const handleOpenMarketplace = useCallback(() => { - openMarketplaces(); - }, []); - - const handleRefresh = useCallback(() => { - dispatch(nftsActions.loadNFTs({ isReplace: true })); - }, [dispatch]); - - const handleLoadMore = useCallback(() => { - if (isLoading || !canLoadMore) { - return; - } - - dispatch(nftsActions.loadNFTs({ isLoadMore: true })); - }, [isLoading, canLoadMore, dispatch]); - - function renderRightButton() { - if (!flags.disable_nft_markets) { - return ( - - - - ); - } - } - - function renderItem({ item, index }) { - return ( - - ); - } - - const keyExtractor = useCallback((item) => item.address, []); - - if (!data.length) { - return ; - } - - return ( - - - - } - numColumns={NUM_OF_COLUMNS} - showsVerticalScrollIndicator={false} - data={data} - scrollEventThrottle={16} - maxToRenderPerBatch={8} - style={{ alignSelf: IsTablet ? 'center' : 'auto' }} - contentContainerStyle={{ - paddingTop: IsTablet ? ns(8) : hNs(LargeNavBarHeight - 4), - paddingHorizontal: ns(16), - paddingBottom: tabBarHeight, - }} - keyExtractor={keyExtractor} - renderItem={renderItem} - onEndReachedThreshold={0.01} - onEndReached={isLoading || !canLoadMore ? undefined : handleLoadMore} - /> - - - ); -}; diff --git a/packages/mobile/src/core/Notifications/Notification.tsx b/packages/mobile/src/core/Notifications/Notification.tsx index 4c38f7937..1127bbc59 100644 --- a/packages/mobile/src/core/Notifications/Notification.tsx +++ b/packages/mobile/src/core/Notifications/Notification.tsx @@ -2,20 +2,21 @@ import { Icon, List, Spacer, Text, View } from '$uikit'; import React, { useCallback, useRef } from 'react'; import { Steezy } from '$styles'; import { INotification } from '$store/zustand/notifications/types'; -import { disableNotifications, useConnectedAppsList } from '$store'; +import { + disableNotifications, + useConnectedAppsList, + useDAppsNotifications, +} from '$store'; import { format, getDomainFromURL } from '$utils'; import { Swipeable, TouchableOpacity } from 'react-native-gesture-handler'; import { IconProps } from '$uikit/Icon/Icon'; -import { useNotificationsStore } from '$store/zustand/notifications/useNotificationsStore'; import { t } from '@tonkeeper/shared/i18n'; import { isToday } from 'date-fns'; import { useActionSheet } from '@expo/react-native-action-sheet'; import { TonConnect } from '$tonconnect'; -import messaging from '@react-native-firebase/messaging'; -import { useSelector } from 'react-redux'; -import { walletAddressSelector } from '$store/wallet'; import { openDAppBrowser } from '$navigation'; import { Alert, Animated } from 'react-native'; +import { useWallet } from '@tonkeeper/shared/hooks'; interface NotificationProps { notification: INotification; @@ -47,17 +48,15 @@ export const Notification: React.FC = (props) => { props.notification.dapp_url && getDomainFromURL(app.url) === getDomainFromURL(props.notification.dapp_url), ); - const walletAddress = useSelector(walletAddressSelector); + const wallet = useWallet(); const { showActionSheetWithOptions } = useActionSheet(); - const deleteNotification = useNotificationsStore( - (state) => state.actions.deleteNotificationByReceivedAt, - ); + const { deleteNotificationByReceivedAt } = useDAppsNotifications(); const listItemRef = useRef(null); const handleDelete = useCallback(() => { - deleteNotification(props.notification.received_at); + deleteNotificationByReceivedAt(props.notification.received_at); props.onRemove?.(); - }, [deleteNotification, props]); + }, [deleteNotificationByReceivedAt, props]); const swipeableRef = useRef(null); @@ -79,7 +78,7 @@ export const Notification: React.FC = (props) => { app?.notificationsEnabled && { option: t('notifications.mute_notifications'), action: async () => { - disableNotifications(walletAddress.ton, app.url); + disableNotifications(wallet.address.ton.friendly, app.url); }, }, app && { @@ -111,7 +110,7 @@ export const Notification: React.FC = (props) => { return; }, ); - }, [app, showActionSheetWithOptions, walletAddress.ton]); + }, [app, showActionSheetWithOptions, wallet]); const renderRightActions = useCallback( (progress) => { diff --git a/packages/mobile/src/core/Notifications/Notifications.tsx b/packages/mobile/src/core/Notifications/Notifications.tsx index e12e21553..54676f1e7 100644 --- a/packages/mobile/src/core/Notifications/Notifications.tsx +++ b/packages/mobile/src/core/Notifications/Notifications.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from 'react'; +import React from 'react'; import { InternalNotification, Screen, @@ -9,91 +9,19 @@ import { View, } from '$uikit'; import { ns } from '$utils'; -import { debugLog } from '$utils/debugLog'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; -import { Linking } from 'react-native'; import { CellSection } from '$shared/components'; -import { getSubscribeStatus, SUBSCRIBE_STATUS } from '$utils/messaging'; -import { useSelector } from 'react-redux'; -import { NotificationsStatus, useNotificationStatus } from '$hooks/useNotificationStatus'; -import messaging from '@react-native-firebase/messaging'; -import { useNotifications } from '$hooks/useNotifications'; import { t } from '@tonkeeper/shared/i18n'; -import { useNotificationsBadge } from '$hooks/useNotificationsBadge'; -import { Toast, ToastSize, useConnectedAppsList, useConnectedAppsStore } from '$store'; +import { useConnectedAppsList } from '$store'; import { Steezy } from '$styles'; -import { getChainName } from '$shared/dynamicConfig'; -import { walletAddressSelector } from '$store/wallet'; import { SwitchDAppNotifications } from '$core/Notifications/SwitchDAppNotifications'; +import { useNotificationsSwitch } from '$hooks/useNotificationsSwitch'; export const Notifications: React.FC = () => { - const address = useSelector(walletAddressSelector); - const handleOpenSettings = useCallback(() => Linking.openSettings(), []); - const notifications = useNotifications(); const tabBarHeight = useBottomTabBarHeight(); - const isSwitchFrozen = useRef(false); - const notificationStatus = useNotificationStatus(); - const notificationsBadge = useNotificationsBadge(); - const shouldEnableNotifications = notificationStatus === NotificationsStatus.DENIED; - const updateNotificationsSubscription = useConnectedAppsStore( - (state) => state.actions.updateNotificationsSubscription, - ); - const [isSubscribeNotifications, setIsSubscribeNotifications] = React.useState(false); - - React.useEffect(() => { - const init = async () => { - const subscribeStatus = await getSubscribeStatus(); - const status = await messaging().hasPermission(); - - const isGratend = - status === NotificationsStatus.AUTHORIZED || - status === NotificationsStatus.PROVISIONAL; - - const initialValue = isGratend && subscribeStatus === SUBSCRIBE_STATUS.SUBSCRIBED; - setIsSubscribeNotifications(initialValue); - }; - - init(); - }, []); - - React.useEffect(() => { - if (notificationsBadge.isVisible) { - notificationsBadge.hide(); - } - }, [notificationsBadge, notificationsBadge.isVisible]); - - const handleToggleNotifications = React.useCallback( - async (value: boolean) => { - if (isSwitchFrozen.current) { - return; - } - - try { - isSwitchFrozen.current = true; - setIsSubscribeNotifications(value); - - const isSuccess = value - ? await notifications.subscribe() - : await notifications.unsubscribe(); - - updateNotificationsSubscription(getChainName(), address.ton); - - if (!isSuccess) { - // Revert - setIsSubscribeNotifications(!value); - } - } catch (err) { - Toast.fail(t('notifications_not_supported'), { size: ToastSize.Small }); - debugLog('[NotificationsSettings]', err); - setIsSubscribeNotifications(!value); // Revert - } finally { - isSwitchFrozen.current = false; - } - }, - [address.ton, notifications, updateNotificationsSubscription], - ); - const connectedApps = useConnectedAppsList(); + const { isSubscribed, isDenied, openSettings, toggleNotifications } = + useNotificationsSwitch(); return ( @@ -104,14 +32,14 @@ export const Notifications: React.FC = () => { paddingBottom: tabBarHeight, }} > - {shouldEnableNotifications && ( + {isDenied && ( )} @@ -119,9 +47,9 @@ export const Notifications: React.FC = () => { {connectedApps.length ? ( diff --git a/packages/mobile/src/core/Notifications/NotificationsActivity.tsx b/packages/mobile/src/core/Notifications/NotificationsActivity.tsx index d1c53623b..b557a98da 100644 --- a/packages/mobile/src/core/Notifications/NotificationsActivity.tsx +++ b/packages/mobile/src/core/Notifications/NotificationsActivity.tsx @@ -1,11 +1,10 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { Button, Icon, Screen, Spacer, Text, View } from '$uikit'; -import { useNotificationsStore } from '$store/zustand/notifications/useNotificationsStore'; import { Notification } from '$core/Notifications/Notification'; import { Steezy } from '$styles'; import { openNotifications } from '$navigation'; import { t } from '@tonkeeper/shared/i18n'; -import { INotification } from '$store'; +import { INotification, useDAppsNotifications } from '$store'; import { FlashList } from '@shopify/flash-list'; import { LayoutAnimation } from 'react-native'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; @@ -48,9 +47,7 @@ export const ListEmpty: React.FC = () => { }; export const NotificationsActivity: React.FC = () => { - const notifications = useNotificationsStore((state) => state.notifications); - const lastSeenAt = useNotificationsStore((state) => state.last_seen); - const updateLastSeen = useNotificationsStore((state) => state.actions.updateLastSeen); + const { notifications, lastSeenAt, updateLastSeen } = useDAppsNotifications(); const list = useRef | null>(null); const closeOtherSwipeable = useRef void)>(null); const lastSwipeableId = useRef(null); diff --git a/packages/mobile/src/core/Notifications/SwitchDAppNotifications.tsx b/packages/mobile/src/core/Notifications/SwitchDAppNotifications.tsx index 2788e1430..8e168a438 100644 --- a/packages/mobile/src/core/Notifications/SwitchDAppNotifications.tsx +++ b/packages/mobile/src/core/Notifications/SwitchDAppNotifications.tsx @@ -5,12 +5,11 @@ import { IConnectedApp, useConnectedAppsStore } from '$store'; import { Steezy } from '$styles'; import { getChainName } from '$shared/dynamicConfig'; import { useObtainProofToken } from '$hooks/useObtainProofToken'; -import { useSelector } from 'react-redux'; -import { walletAddressSelector } from '$store/wallet'; import { useIsFocused } from '@react-navigation/native'; +import { useWallet } from '@tonkeeper/shared/hooks'; const SwitchDAppNotificationsComponent: React.FC<{ app: IConnectedApp }> = ({ app }) => { - const address = useSelector(walletAddressSelector); + const wallet = useWallet(); const [switchValue, setSwitchValue] = React.useState(!!app.notificationsEnabled); const [isFrozen, setIsFrozen] = React.useState(false); const isFocused = useIsFocused(); @@ -37,9 +36,14 @@ const SwitchDAppNotificationsComponent: React.FC<{ app: IConnectedApp }> = ({ ap return setSwitchValue(!value); } if (value) { - await enableNotifications(getChainName(), address.ton, url, session_id); + await enableNotifications( + getChainName(), + wallet.address.ton.friendly, + url, + session_id, + ); } else { - await disableNotifications(getChainName(), address.ton, url); + await disableNotifications(getChainName(), wallet.address.ton.friendly, url); } setIsFrozen(false); } catch (error) { @@ -47,13 +51,7 @@ const SwitchDAppNotificationsComponent: React.FC<{ app: IConnectedApp }> = ({ ap setSwitchValue(!value); } }, - [ - setIsFrozen, - obtainProofToken, - disableNotifications, - address.ton, - enableNotifications, - ], + [obtainProofToken, enableNotifications, wallet, disableNotifications], ); return ( diff --git a/packages/mobile/src/core/ResetPin/ResetPin.style.ts b/packages/mobile/src/core/ResetPin/ResetPin.style.ts deleted file mode 100644 index c79bf2efc..000000000 --- a/packages/mobile/src/core/ResetPin/ResetPin.style.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Animated } from 'react-native'; -import Renimated from 'react-native-reanimated'; - -import styled from '$styled'; -import { deviceWidth } from '$utils'; - -export const Steps = styled(Renimated.View)` - flex-direction: row; - flex: 1; -`; - -export const Step = styled.View` - flex: 0 0 auto; - width: ${deviceWidth}px; -`; - -export const ImportWrap = styled(Animated.View)` - flex: 1; -`; diff --git a/packages/mobile/src/core/ResetPin/ResetPin.tsx b/packages/mobile/src/core/ResetPin/ResetPin.tsx deleted file mode 100644 index 3a0366ddc..000000000 --- a/packages/mobile/src/core/ResetPin/ResetPin.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; -import { - Easing, - interpolate, - useAnimatedStyle, - useSharedValue, - withDelay, - withTiming, -} from 'react-native-reanimated'; -import { Keyboard } from 'react-native'; -import { useDispatch } from 'react-redux'; -import * as LocalAuthentication from 'expo-local-authentication'; -import * as SecureStore from 'expo-secure-store'; - -import { NavBar } from '$uikit'; -import * as S from './ResetPin.style'; -import { CreatePinForm, ImportWalletForm } from '$shared/components'; -import { detectBiometryType, deviceWidth } from '$utils'; -import { debugLog } from '$utils/debugLog'; -import { useKeyboardHeight } from '$hooks/useKeyboardHeight'; -import { walletActions } from '$store/wallet'; -import { goBack, popToTop } from '$navigation/imperative'; -import { openSetupBiometryAfterRestore } from '$navigation'; -import { Toast } from '$store'; - -export const ResetPin: FC = () => { - const [step, setStep] = useState(0); - const keyboardHeight = useKeyboardHeight(); - const dispatch = useDispatch(); - - const stepsValue = useSharedValue(0); - - useEffect(() => { - stepsValue.value = withDelay( - 500, - withTiming(step === 0 ? 0 : 1, { - duration: 300, - easing: Easing.inOut(Easing.ease), - }), - ); - }, [step, stepsValue]); - - const handleWordsFilled = useCallback( - (mnemonics: string, config: any, onEnd: () => void) => { - dispatch( - walletActions.restoreWallet({ - mnemonics, - config, - onDone: () => { - Keyboard.dismiss(); - setStep(1); - }, - onFail: () => onEnd(), - }), - ); - }, - [dispatch], - ); - - const doCreateWallet = useCallback( - (pin: string) => { - dispatch( - walletActions.createWallet({ - pin, - onDone: () => { - popToTop(); - Toast.success(); - setTimeout(() => goBack(), 20); - }, - onFail: () => {}, - }), - ); - }, - [dispatch], - ); - - const handlePinCreated = useCallback( - (pin: string) => { - Promise.all([ - LocalAuthentication.supportedAuthenticationTypesAsync(), - SecureStore.isAvailableAsync(), - ]) - .then(([types, isProtected]) => { - const biometryType = detectBiometryType(types); - if (biometryType && isProtected) { - openSetupBiometryAfterRestore(pin, biometryType); - } else { - doCreateWallet(pin); - } - }) - .catch((err) => { - console.log('ERR2', err); - debugLog('supportedAuthenticationTypesAsync', err.message); - doCreateWallet(pin); - }); - }, - [doCreateWallet], - ); - - const stepsStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateX: interpolate(stepsValue.value, [0, 1], [0, -deviceWidth]), - }, - ], - }; - }); - - return ( - <> - - - - - - - - - - - - - ); -}; diff --git a/packages/mobile/src/core/SecretWords/SecretWords.style.ts b/packages/mobile/src/core/SecretWords/SecretWords.style.ts deleted file mode 100644 index 4d9ff759a..000000000 --- a/packages/mobile/src/core/SecretWords/SecretWords.style.ts +++ /dev/null @@ -1,34 +0,0 @@ -import styled from '$styled'; -import { nfs, ns } from '$utils'; - -export const Content = styled.View` - padding: 0 0 ${ns(32)}px; - flex: 1; -`; - -export const Words = styled.View` - flex-direction: row; - padding-top: ${ns(24)}px; - width: 100%; - justify-content: space-between; -`; - -export const WordsColumn = styled.View` - flex: 0 0 auto; - width: ${ns(122)}px; -`; - -export const WordsItem = styled.View` - flex-direction: row; - height: ${ns(24)}px; - margin-top: ${ns(8)}px; - align-items: center; -`; - -export const WordsItemNumberWrapper = styled.View` - width: ${ns(24)}px; -`; - -export const WordsItemValueWrapper = styled.View` - margin-left: ${ns(4)}px; -`; diff --git a/packages/mobile/src/core/SecretWords/SecretWords.tsx b/packages/mobile/src/core/SecretWords/SecretWords.tsx deleted file mode 100644 index 2c12a8276..000000000 --- a/packages/mobile/src/core/SecretWords/SecretWords.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { FC, useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { ScrollView, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import * as CreateWalletStyle from '../CreateWallet/CreateWallet.style'; -import {Button, NavBar, NavBarHelper, Text} from '$uikit'; -import { ns } from '$utils'; -import { walletSelector } from '$store/wallet'; -import * as S from './SecretWords.style'; -import { t } from '@tonkeeper/shared/i18n'; -import { openCheckSecretWords } from '$navigation'; -import {WordsItemNumberWrapper} from "./SecretWords.style"; - -export const SecretWords: FC = () => { - - const { generatedVault } = useSelector(walletSelector); - const { bottom: bottomInset } = useSafeAreaInsets(); - - const data = useMemo(() => { - const words = generatedVault!.mnemonic.split(' '); - return { - firstColumn: words.splice(0, 12), - secondColumn: words, - }; - }, [generatedVault]); - - function renderColumn(words: string[], column: number) { - return words.map((word, i) => { - let number = i + 1; - if (column === 2) { - number += 12; - } - return ( - - - - {number}. - - - - {word} - - - ); - }); - } - - const handleContinue = useCallback(() => { - openCheckSecretWords(); - }, []); - - return ( - - - - - - - {t('secret_words_title')} - - - - {t('secret_words_caption')} - - - - {renderColumn(data.firstColumn, 1)} - {renderColumn(data.secondColumn, 2)} - - - - - - - - ); -}; diff --git a/packages/mobile/src/core/Security/Security.tsx b/packages/mobile/src/core/Security/Security.tsx index 937a0e501..922d72fa9 100644 --- a/packages/mobile/src/core/Security/Security.tsx +++ b/packages/mobile/src/core/Security/Security.tsx @@ -1,88 +1,64 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { FC, useCallback } from 'react'; import Animated from 'react-native-reanimated'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import Clipboard from '@react-native-community/clipboard'; -import * as LocalAuthentication from 'expo-local-authentication'; -import { Switch } from 'react-native'; import * as S from './Security.style'; -import {NavBar, ScrollHandler, Text} from '$uikit'; +import { NavBar, ScrollHandler, Text } from '$uikit'; import { CellSection, CellSectionItem } from '$shared/components'; -import { walletActions, walletSelector } from '$store/wallet'; -import { openChangePin, openResetPin } from '$navigation'; -import { detectBiometryType, ns, platform, triggerImpactLight } from '$utils'; -import { MainDB } from '$database'; +import { MainStackRouteNames, openChangePin } from '$navigation'; +import { getBiometryName, ns } from '$utils'; import { Toast } from '$store'; -import { openRequireWalletModal } from '$core/ModalContainer/RequireWallet/RequireWallet'; import { t } from '@tonkeeper/shared/i18n'; +import { useBiometrySettings, useWallet } from '@tonkeeper/shared/hooks'; +import { useNavigation } from '@tonkeeper/router'; +import { vault } from '$wallet'; +import { Haptics, Switch } from '@tonkeeper/uikit'; export const Security: FC = () => { - const dispatch = useDispatch(); const tabBarHeight = useBottomTabBarHeight(); - const { wallet } = useSelector(walletSelector); - const [isBiometryEnabled, setBiometryEnabled] = useState(false); - const [biometryAvail, setBiometryAvail] = useState(-1); - const isTouchId = - biometryAvail !== LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION; + const wallet = useWallet(); + const nav = useNavigation(); - useEffect(() => { - Promise.all([ - MainDB.isBiometryEnabled(), - LocalAuthentication.supportedAuthenticationTypesAsync(), - ]).then(([isEnabled, types]) => { - setBiometryEnabled(isEnabled); - setBiometryAvail(detectBiometryType(types) || -1); - }); - }, []); - - const handleBackupSettings = useCallback(() => { - if (!wallet) { - return openRequireWalletModal(); - } - - // TODO: wrap this into something that support UI for password decryption for EncryptedVault. - dispatch(walletActions.backupWallet()); - }, [dispatch, wallet]); + const biometry = useBiometrySettings(); const handleCopyLockupConfig = useCallback(() => { try { - Clipboard.setString(JSON.stringify(wallet!.vault.getLockupConfig())); + Clipboard.setString(JSON.stringify(wallet.getLockupConfig())); Toast.success(t('copied')); } catch (e) { Toast.fail(e.message); } - }, [t, wallet]); + }, [wallet]); const handleBiometry = useCallback( (triggerHaptic: boolean) => () => { - const newValue = !isBiometryEnabled; - setBiometryEnabled(newValue); - if (triggerHaptic) { - triggerImpactLight(); + Haptics.impactLight(); } - dispatch( - walletActions.toggleBiometry({ - isEnabled: newValue, - onFail: () => setBiometryEnabled(!newValue), - }), - ); + biometry.toggleBiometry(); }, - [dispatch, isBiometryEnabled], + [biometry], ); const handleChangePasscode = useCallback(() => { openChangePin(); }, []); - const handleResetPasscode = useCallback(() => { - openResetPin(); - }, []); + const handleResetPasscode = useCallback(async () => { + if (!biometry.isEnabled) { + return; + } + + try { + const passcode = await vault.exportPasscodeWithBiometry(); + nav.navigate(MainStackRouteNames.ResetPin, { passcode }); + } catch {} + }, [biometry.isEnabled, nav]); function renderBiometryToggler() { - if (biometryAvail === -1) { + if (!biometry.isAvailable) { return null; } @@ -92,23 +68,17 @@ export const Security: FC = () => { + } > {t('security_use_biometry_switch', { - biometryType: isTouchId - ? t(`platform.${platform}.fingerprint`) - : t(`platform.${platform}.face_recognition`), + biometryType: getBiometryName(biometry.type, { accusative: true }), })} - {t('security_use_biometry_tip', { - biometryType: isTouchId - ? t(`platform.${platform}.fingerprint`) - : t(`platform.${platform}.face_recognition`), - })} + {t('security_use_biometry_tip')} @@ -132,20 +102,17 @@ export const Security: FC = () => { {t('security_change_passcode')} - - {t('security_reset_passcode')} - + {biometry.isEnabled ? ( + + {t('security_reset_passcode')} + + ) : null} - {!!wallet && ( - - {t('settings_backup_seed')} - - )} - {!!wallet && wallet.ton.isLockup() && ( + {!!wallet && wallet.isLockup && ( Copy lockup config diff --git a/packages/mobile/src/core/SecurityMigration/SecurityMigration.style.ts b/packages/mobile/src/core/SecurityMigration/SecurityMigration.style.ts deleted file mode 100644 index 0207eb279..000000000 --- a/packages/mobile/src/core/SecurityMigration/SecurityMigration.style.ts +++ /dev/null @@ -1,33 +0,0 @@ -import LottieView from 'lottie-react-native'; - -import styled from '$styled'; -import { nfs, ns } from '$utils'; - -export const Wrap = styled.SafeAreaView` - flex: 1; -`; - -export const Info = styled.View` - flex: 1; - align-items: center; - justify-content: center; - padding: ${ns(32)}px; -`; - -export const LottieIcon = styled(LottieView)` - width: ${ns(120)}px; - height: ${ns(120)}px; -`; - -export const TitleWrapper = styled.View` - margin-top: ${ns(16)}px; -`; - -export const CaptionWrapper = styled.View` - margin-top: ${ns(4)}px; -`; - -export const Footer = styled.View` - flex: 0 0 auto; - padding: ${ns(32)}px; -`; diff --git a/packages/mobile/src/core/SecurityMigration/SecurityMigration.tsx b/packages/mobile/src/core/SecurityMigration/SecurityMigration.tsx deleted file mode 100644 index e2d546c05..000000000 --- a/packages/mobile/src/core/SecurityMigration/SecurityMigration.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { FC, useCallback, useEffect, useRef } from 'react'; -import LottieView from 'lottie-react-native'; -import { useDispatch } from 'react-redux'; - -import * as S from './SecurityMigration.style'; -import {Button, Text} from '$uikit'; -import { ns, platform } from '$utils'; -import { goBack } from '$navigation/imperative'; -import { walletActions } from '$store/wallet'; -import { t } from '@tonkeeper/shared/i18n'; - -export const SecurityMigration: FC = () => { - const dispatch = useDispatch(); - const iconRef = useRef(null); - - useEffect(() => { - const timer = setTimeout(() => { - iconRef.current?.play(); - }, 100); - - return () => clearTimeout(timer); - }, []); - - const handleMigrate = useCallback(() => { - dispatch(walletActions.securityMigrate()); - }, [dispatch]); - - const handleSkip = useCallback(() => { - goBack(); - }, []); - - return ( - - - - - - {t('security_migration_title')} - - - - - {t('security_migration_caption', { - faceRecognition: t(`platform.${platform}.face_recognition`) - })} - - - - - - - - - ); -}; diff --git a/packages/mobile/src/core/Send/Send.tsx b/packages/mobile/src/core/Send/Send.tsx index d6c585eb4..03b53a4de 100644 --- a/packages/mobile/src/core/Send/Send.tsx +++ b/packages/mobile/src/core/Send/Send.tsx @@ -2,12 +2,7 @@ import { useInstance } from '$hooks/useInstance'; import { useTokenPrice } from '$hooks/useTokenPrice'; import { useCurrencyToSend } from '$hooks/useCurrencyToSend'; import { StepView, StepViewItem, StepViewRef } from '$shared/components'; -import { - CryptoCurrencies, - CryptoCurrency, - Decimals, - getServerConfig, -} from '$shared/constants'; +import { CryptoCurrencies, CryptoCurrency, Decimals } from '$shared/constants'; import { walletActions } from '$store/wallet'; import { NavBar, Text } from '$uikit'; import { parseLocaleNumber } from '$utils'; @@ -46,7 +41,7 @@ import { Events } from '$store/models'; import { trackEvent } from '$utils/stats'; import { t } from '@tonkeeper/shared/i18n'; import { Address } from '@tonkeeper/core'; -import { tk } from '@tonkeeper/shared/tonkeeper'; +import { tk } from '$wallet'; import { useUnlockVault } from '$core/ModalContainer/NFTOperations/useUnlockVault'; import { useValueRef } from '@tonkeeper/uikit'; import { RequestData } from '@tonkeeper/core/src/TronAPI/TronAPIGenerated'; @@ -56,6 +51,7 @@ import { } from '$core/ModalContainer/InsufficientFunds/InsufficientFunds'; import { getTimeSec } from '$utils/getTimeSec'; import { Toast } from '$store'; +import { config } from '$config'; const tokensWithAllowedEncryption = [TokenType.TON, TokenType.Jetton]; @@ -89,9 +85,9 @@ export const Send: FC = ({ route }) => { const accountsApi = useInstance(() => { const tonApiConfiguration = new Configuration({ - basePath: getServerConfig('tonapiV2Endpoint'), + basePath: config.get('tonapiV2Endpoint', tk.wallet.isTestnet), headers: { - Authorization: `Bearer ${getServerConfig('tonApiV2Key')}`, + Authorization: `Bearer ${config.get('tonApiV2Key', tk.wallet.isTestnet)}`, }, }); diff --git a/packages/mobile/src/core/Send/hooks/useSuggestedAddresses.ts b/packages/mobile/src/core/Send/hooks/useSuggestedAddresses.ts index 295a683ee..994787236 100644 --- a/packages/mobile/src/core/Send/hooks/useSuggestedAddresses.ts +++ b/packages/mobile/src/core/Send/hooks/useSuggestedAddresses.ts @@ -4,12 +4,10 @@ import uniqBy from 'lodash/uniqBy'; import { useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { SuggestedAddress, SuggestedAddressType } from '../Send.interface'; -import { walletAddressSelector } from '$store/wallet'; -import { CryptoCurrencies } from '$shared/constants'; -import { Tonapi } from '$libs/Tonapi'; -import { useStakingStore } from '$store'; -import { ActionItem, ActionType, Address } from '@tonkeeper/core'; -import { tk } from '@tonkeeper/shared/tonkeeper'; +import { Address } from '@tonkeeper/core'; +import { tk } from '$wallet'; +import { useWallet } from '@tonkeeper/shared/hooks'; +import { ActionItem, ActionType } from '$wallet/models/ActivityModel'; export const DOMAIN_ADDRESS_NOT_FOUND = 'DOMAIN_ADDRESS_NOT_FOUND'; @@ -20,9 +18,7 @@ export const useSuggestedAddresses = () => { const dispatch = useDispatch(); const { favorites, hiddenRecentAddresses, updatedDnsAddresses } = useSelector(favoritesSelector); - const address = useSelector(walletAddressSelector); - - const stakingPools = useStakingStore((s) => s.pools.map((pool) => pool.address)); + const wallet = useWallet(); const favoriteAddresses = useMemo( (): SuggestedAddress[] => @@ -48,7 +44,7 @@ export const useSuggestedAddresses = () => { ActionType.TonTransfer, ] as const; - const walletAddress = address[CryptoCurrencies.Ton]; + const walletAddress = wallet.address.ton.raw; const addresses = ( actions.filter((action) => { if ( @@ -66,7 +62,8 @@ export const useSuggestedAddresses = () => { !recipientAddress || Address.compare(walletAddress, recipientAddress) || payload.sender?.is_scam || - payload.recipient?.is_scam + payload.recipient?.is_scam || + !payload.recipient?.is_wallet ) { return false; } @@ -76,17 +73,11 @@ export const useSuggestedAddresses = () => { Address.compare(favorite.address, recipientAddress), ) !== -1; - const isStakingPool = - stakingPools.findIndex((poolAddress) => - Address.compare(poolAddress, recipientAddress), - ) !== -1; - const rawAddress = Address.parse(recipientAddress).toRaw(); if ( hiddenRecentAddresses.some((addr) => Address.compare(addr, rawAddress)) || - isFavorite || - isStakingPool + isFavorite ) { return false; } @@ -105,7 +96,7 @@ export const useSuggestedAddresses = () => { ); return uniqBy(addresses, (item) => item.address).slice(0, 8); - }, [address, favoriteAddresses, hiddenRecentAddresses, stakingPools]); + }, [favoriteAddresses, hiddenRecentAddresses, wallet]); const suggestedAddresses = useMemo( () => [...favoriteAddresses, ...recentAddresses], @@ -125,7 +116,7 @@ export const useSuggestedAddresses = () => { } for (const favorite of dnsFavorites) { - const resolved = await Tonapi.resolveDns(favorite.domain!); + const resolved = await tk.wallet.tonapi.dns.dnsResolve(favorite.domain!); const fetchedAddress = resolved?.wallet?.address; if (fetchedAddress && !Address.compare(favorite.address, fetchedAddress)) { diff --git a/packages/mobile/src/core/Send/steps/AddressStep/components/AddressInput/AddressInput.tsx b/packages/mobile/src/core/Send/steps/AddressStep/components/AddressInput/AddressInput.tsx index ecfd853c4..df6355eaf 100644 --- a/packages/mobile/src/core/Send/steps/AddressStep/components/AddressInput/AddressInput.tsx +++ b/packages/mobile/src/core/Send/steps/AddressStep/components/AddressInput/AddressInput.tsx @@ -22,13 +22,14 @@ import { TextInput } from 'react-native-gesture-handler'; import { Address } from '@tonkeeper/core'; interface Props { - wordHintsRef: RefObject; + wordHintsRef?: RefObject; shouldFocus: boolean; recipient: SendRecipient | null; dnsLoading: boolean; editable: boolean; + error?: boolean; updateRecipient: (value: string) => Promise; - onSubmit: () => void; + onSubmit?: () => void; } const AddressInputComponent: FC = (props) => { @@ -36,6 +37,7 @@ const AddressInputComponent: FC = (props) => { wordHintsRef, shouldFocus, recipient, + error, dnsLoading, editable, updateRecipient, @@ -49,7 +51,7 @@ const AddressInputComponent: FC = (props) => { const [showFailed, setShowFailed] = useState(true); - const isFailed = showFailed && !dnsLoading && value.length > 0 && !recipient; + const isFailed = error || (showFailed && !dnsLoading && value.length > 0 && !recipient); const canScanQR = value.length === 0; @@ -59,7 +61,7 @@ const AddressInputComponent: FC = (props) => { const offsetTop = S.INPUT_HEIGHT + ns(isAndroid ? 20 : 16); const offsetLeft = ns(-16); - wordHintsRef.current?.search({ + wordHintsRef?.current?.search({ input: 0, query: inputValue.current, offsetTop, @@ -88,18 +90,18 @@ const AddressInputComponent: FC = (props) => { ); const handleBlur = useCallback(() => { - wordHintsRef.current?.clear(); + wordHintsRef?.current?.clear(); }, [wordHintsRef]); const handleSubmit = useCallback(() => { - const hint = wordHintsRef.current?.getCurrentSuggests()?.[0]; + const hint = wordHintsRef?.current?.getCurrentSuggests()?.[0]; if (hint) { updateRecipient(hint); return; } - onSubmit(); + onSubmit?.(); }, [onSubmit, updateRecipient, wordHintsRef]); const contentWidth = useSharedValue(0); @@ -185,12 +187,12 @@ const AddressInputComponent: FC = (props) => { inputValue.current = nextValue; - wordHintsRef.current?.clear(); + wordHintsRef?.current?.clear(); }, [recipient, wordHintsRef]); const preparedAddress = recipient && (recipient.name || recipient.domain) - ? Address.toShort(recipient.address) + ? Address.parse(recipient.address, { bounceable: false }).toShort() : ''; const isFirstRender = useRef(true); diff --git a/packages/mobile/src/core/Send/steps/AmountStep/AmountStep.tsx b/packages/mobile/src/core/Send/steps/AmountStep/AmountStep.tsx index d285cfb76..edc2dd2b0 100644 --- a/packages/mobile/src/core/Send/steps/AmountStep/AmountStep.tsx +++ b/packages/mobile/src/core/Send/steps/AmountStep/AmountStep.tsx @@ -4,14 +4,13 @@ import React, { FC, memo, useEffect, useMemo, useRef } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as S from './AmountStep.style'; import { parseLocaleNumber } from '$utils'; -import { useSelector } from 'react-redux'; import BigNumber from 'bignumber.js'; import { AmountStepProps } from './AmountStep.interface'; -import { walletWalletSelector } from '$store/wallet'; import { AmountInput, AmountInputRef } from '$shared/components'; import { CoinDropdown } from './CoinDropdown'; import { t } from '@tonkeeper/shared/i18n'; import { Steezy, View, Text } from '@tonkeeper/uikit'; +import { useWallet } from '@tonkeeper/shared/hooks'; const AmountStepComponent: FC = (props) => { const { @@ -29,9 +28,9 @@ const AmountStepComponent: FC = (props) => { onChangeCurrency, } = props; - const wallet = useSelector(walletWalletSelector); + const wallet = useWallet(); - const isLockup = !!wallet?.ton.isLockup(); + const isLockup = !!wallet?.isLockup; const { isReadyToContinue } = useMemo(() => { const bigNum = new BigNumber(parseLocaleNumber(amount.value)); diff --git a/packages/mobile/src/core/Send/steps/AmountStep/CoinDropdown/CoinDropdown.tsx b/packages/mobile/src/core/Send/steps/AmountStep/CoinDropdown/CoinDropdown.tsx index 06350d919..36233f4c0 100644 --- a/packages/mobile/src/core/Send/steps/AmountStep/CoinDropdown/CoinDropdown.tsx +++ b/packages/mobile/src/core/Send/steps/AmountStep/CoinDropdown/CoinDropdown.tsx @@ -1,12 +1,10 @@ import { useJettonBalances } from '$hooks/useJettonBalances'; -import { CryptoCurrencies, Decimals, SecondaryCryptoCurrencies } from '$shared/constants'; +import { CryptoCurrencies, Decimals } from '$shared/constants'; import { JettonBalanceModel } from '$store/models'; -import { walletSelector } from '$store/wallet'; import { Steezy } from '$styles'; import { Highlight, Icon, PopupSelect, Spacer, Text, View } from '$uikit'; import { ns } from '$utils'; import React, { FC, memo, useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; import { useHideableFormatter } from '$core/HideableAmount/useHideableFormatter'; import { DEFAULT_TOKEN_LOGO, JettonIcon, TonIcon } from '@tonkeeper/uikit'; import { @@ -16,6 +14,7 @@ import { } from '$core/Send/Send.interface'; import { useTonInscriptions } from '@tonkeeper/shared/query/hooks/useTonInscriptions'; import { formatter } from '@tonkeeper/shared/formatter'; +import { useBalancesState } from '@tonkeeper/shared/hooks'; type CoinItem = | { @@ -55,37 +54,20 @@ interface Props { const CoinDropdownComponent: FC = (props) => { const { currency, currencyTitle, onChangeCurrency } = props; - const { currencies, balances } = useSelector(walletSelector); + const balances = useBalancesState(); const { enabled: jettons } = useJettonBalances(false, true); const inscriptions = useTonInscriptions(); const { format } = useHideableFormatter(); const coins = useMemo((): CoinItem[] => { - const list = [ - CryptoCurrencies.Ton, - ...SecondaryCryptoCurrencies.filter((item) => { - if (item === CryptoCurrencies.Ton) { - return false; - } - - if (+balances[item] > 0) { - return true; - } - - return currencies.indexOf(item) > -1; - }), - ].map( - (item): CoinItem => ({ - tokenType: TokenType.TON, - currency: item, - balance: balances[item], - decimals: Decimals[item], - }), - ); - return [ - ...list, + { + tokenType: TokenType.TON, + currency: CryptoCurrencies.Ton, + balance: balances.ton, + decimals: Decimals[CryptoCurrencies.Ton], + }, ...jettons.map((jetton): CoinItem => { return { tokenType: TokenType.Jetton, @@ -107,7 +89,7 @@ const CoinDropdownComponent: FC = (props) => { }; }), ]; - }, [jettons, inscriptions.items, balances, currencies]); + }, [jettons, inscriptions.items, balances]); const selectedCoin = useMemo( () => coins.find((item) => item.currency === currency), diff --git a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx index d70aecb3f..c9478a2ae 100644 --- a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx +++ b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx @@ -2,7 +2,6 @@ import { useCopyText } from '$hooks/useCopyText'; import { useFiatValue } from '$hooks/useFiatValue'; import { BottomButtonWrapHelper, StepScrollView } from '$shared/components'; import { CryptoCurrencies, CryptoCurrency, Decimals } from '$shared/constants'; -import { getTokenConfig } from '$shared/dynamicConfig'; import { Highlight, Icon, Separator, Spacer, StakedTonIcon, Text } from '$uikit'; import { parseLocaleNumber } from '$utils'; import React, { FC, memo, useCallback, useEffect, useMemo } from 'react'; @@ -17,8 +16,6 @@ import { useActionFooter, } from '$core/ModalContainer/NFTOperations/NFTOperationFooter'; import { Alert } from 'react-native'; -import { walletBalancesSelector, walletWalletSelector } from '$store/wallet'; -import { useSelector } from 'react-redux'; import { SkeletonLine } from '$uikit/Skeleton/SkeletonLine'; import { t } from '@tonkeeper/shared/i18n'; import { openInactiveInfo } from '$core/ModalContainer/InfoAboutInactive/InfoAboutInactive'; @@ -26,6 +23,8 @@ import { Address } from '@tonkeeper/core'; import { useBatteryState } from '@tonkeeper/shared/query/hooks/useBatteryState'; import { BatteryState } from '@tonkeeper/shared/utils/battery'; import { TokenType } from '$core/Send/Send.interface'; +import { useBalancesState, useWallet } from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; const ConfirmStepComponent: FC = (props) => { const { @@ -53,8 +52,8 @@ const ConfirmStepComponent: FC = (props) => { const copyText = useCopyText(); - const balances = useSelector(walletBalancesSelector); - const wallet = useSelector(walletWalletSelector); + const balances = useBalancesState(); + const wallet = useWallet(); const batteryState = useBatteryState(); const { Logo, liquidJettonPool } = useCurrencyToSend(currency, tokenType); @@ -103,9 +102,9 @@ const ConfirmStepComponent: FC = (props) => { if ( currency === CryptoCurrencies.Ton && wallet && - wallet.ton.isLockup() && + wallet.isLockup && !amount.all && - new BigNumber(balances[currency]).isLessThan(amountWithFee) + new BigNumber(balances.ton).isLessThan(amountWithFee) ) { await showLockupAlert(); } @@ -133,18 +132,7 @@ const ConfirmStepComponent: FC = (props) => { sendTx, ]); - const feeCurrency = useMemo(() => { - const tokenConfig = getTokenConfig(currency as CryptoCurrency); - if (currency === 'usdt') { - return 'USDT'; - } else if (tokenConfig && tokenConfig.blockchain === 'ethereum') { - return CryptoCurrencies.Eth; - } else if ([TokenType.Jetton, TokenType.Inscription].includes(tokenType)) { - return CryptoCurrencies.Ton; - } else { - return currency; - } - }, [currency, tokenType]); + const feeCurrency = CryptoCurrencies.Ton; const calculatedValue = useMemo(() => { if (amount.all && tokenType === TokenType.TON) { @@ -233,15 +221,30 @@ const ConfirmStepComponent: FC = (props) => { + {tk.wallets.size > 1 && ( + <> + + {t('send_screen_steps.comfirm.wallet')} + + + {tk.wallet.config.emoji} {tk.wallet.config.name} + + + + + + )} {recipientName ? ( - - {t('confirm_sending_recipient')} - - {recipientName} - - + <> + + {t('confirm_sending_recipient')} + + {recipientName} + + + + ) : null} - diff --git a/packages/mobile/src/core/Settings/Settings.style.ts b/packages/mobile/src/core/Settings/Settings.style.ts index 3d8b061af..bb4c5b13e 100644 --- a/packages/mobile/src/core/Settings/Settings.style.ts +++ b/packages/mobile/src/core/Settings/Settings.style.ts @@ -49,7 +49,7 @@ export const SelectedCurrency = styled.Text` line-height: 24px; `; -export const NotificationDeniedIndicator = styled.View` +export const BackupIndicator = styled.View` background-color: ${({ theme }) => theme.colors.accentNegative}; width: ${ns(8)}px; height: ${ns(8)}px; diff --git a/packages/mobile/src/core/Settings/Settings.tsx b/packages/mobile/src/core/Settings/Settings.tsx index 6685b93ec..6f7bbc70e 100644 --- a/packages/mobile/src/core/Settings/Settings.tsx +++ b/packages/mobile/src/core/Settings/Settings.tsx @@ -1,7 +1,7 @@ import React, { FC, useCallback, useMemo, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import Rate, { AndroidMarket } from 'react-native-rate'; -import { Alert, Linking, View } from 'react-native'; +import { Alert, Linking, Platform, View } from 'react-native'; import DeviceInfo from 'react-native-device-info'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import Animated from 'react-native-reanimated'; @@ -12,11 +12,11 @@ import { Icon, PopupSelect, ScrollHandler, Spacer, Text } from '$uikit'; import { Icon as NewIcon } from '@tonkeeper/uikit'; import { useShouldShowTokensButton } from '$hooks/useShouldShowTokensButton'; import { useNavigation } from '@tonkeeper/router'; -import { fiatCurrencySelector, showV4R1Selector } from '$store/main'; -import { hasSubscriptionsSelector } from '$store/subscriptions'; import { List } from '@tonkeeper/uikit'; import { + AppStackRouteNames, MainStackRouteNames, + SettingsStackRouteNames, openDeleteAccountDone, openDevMenu, openLegalDocuments, @@ -24,41 +24,38 @@ import { openNotifications, openRefillBattery, openSecurity, - openSecurityMigration, + openSelectLanguage, openSubscriptions, } from '$navigation'; -import { - walletActions, - walletVersionSelector, - walletWalletSelector, -} from '$store/wallet'; +import { walletActions } from '$store/wallet'; import { APPLE_STORE_ID, - getServerConfig, GOOGLE_PACKAGE_NAME, LargeNavBarHeight, - SelectableVersion, - SelectableVersionsConfig, IsTablet, - SelectableVersions, } from '$shared/constants'; -import { hNs, ns, throttle, useHasDiamondsOnBalance } from '$utils'; +import { checkIsTonDiamondsNFT, hNs, ns, throttle } from '$utils'; import { LargeNavBarInteractiveDistance } from '$uikit/LargeNavBar/LargeNavBar'; import { CellSectionItem } from '$shared/components'; -import { MainDB } from '$database'; -import { useNotifications } from '$hooks/useNotifications'; -import { useNotificationsBadge } from '$hooks/useNotificationsBadge'; -import { useAllAddresses } from '$hooks/useAllAddresses'; import { useFlags } from '$utils/flags'; -import { SearchEngine, useBrowserStore, useNotificationsStore } from '$store'; +import { SearchEngine, useBrowserStore } from '$store'; import AnimatedLottieView from 'lottie-react-native'; import { Steezy } from '$styles'; -import { t } from '@tonkeeper/shared/i18n'; +import { i18n, t } from '@tonkeeper/shared/i18n'; import { trackEvent } from '$utils/stats'; import { openAppearance } from '$core/ModalContainer/AppearanceModal'; -import { Address } from '@tonkeeper/core'; -import { shouldShowNotifications } from '$store/zustand/notifications/selectors'; -import { config } from '@tonkeeper/shared/config'; +import { config } from '$config'; +import { + useNftsState, + useWallet, + useWalletCurrency, + useWalletSetup, +} from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; +import { mapNewNftToOldNftData } from '$utils/mapNewNftToOldNftData'; +import { WalletListItem } from '@tonkeeper/shared/components'; +import { useSubscriptions } from '@tonkeeper/shared/hooks/useSubscriptions'; +import { nativeLocaleNames } from '@tonkeeper/shared/i18n/translations'; export const Settings: FC = () => { const animationRef = useRef(null); @@ -74,20 +71,19 @@ export const Settings: FC = () => { const nav = useNavigation(); const tabBarHeight = useBottomTabBarHeight(); - const notificationsBadge = useNotificationsBadge(); - const notifications = useNotifications(); - const fiatCurrency = useSelector(fiatCurrencySelector); + const fiatCurrency = useWalletCurrency(); const dispatch = useDispatch(); - const hasSubscriptions = useSelector(hasSubscriptionsSelector); - const wallet = useSelector(walletWalletSelector); - const version = useSelector(walletVersionSelector); - const allTonAddesses = useAllAddresses(); - const showV4R1 = useSelector(showV4R1Selector); + const hasSubscriptions = useSubscriptions( + (state) => Object.values(state.subscriptions).length > 0, + ); + const wallet = useWallet(); const shouldShowTokensButton = useShouldShowTokensButton(); - const showNotifications = useNotificationsStore(shouldShowNotifications); - const isBatteryVisible = !config.get('disable_battery'); + const { lastBackupAt } = useWalletSetup(); + + const isBatteryVisible = + !!wallet && !wallet.isWatchOnly && !config.get('disable_battery'); const searchEngine = useBrowserStore((state) => state.searchEngine); const setSearchEngine = useBrowserStore((state) => state.actions.setSearchEngine); @@ -114,7 +110,7 @@ export const Settings: FC = () => { }, []); const handleFeedback = useCallback(() => { - Linking.openURL(getServerConfig('supportLink')).catch((err) => console.log(err)); + Linking.openURL(config.get('supportLink')).catch((err) => console.log(err)); }, []); const handleLegal = useCallback(() => { @@ -122,11 +118,11 @@ export const Settings: FC = () => { }, []); const handleNews = useCallback(() => { - Linking.openURL(getServerConfig('tonkeeperNewsUrl')).catch((err) => console.log(err)); + Linking.openURL(config.get('tonkeeperNewsUrl')).catch((err) => console.log(err)); }, []); const handleSupport = useCallback(() => { - Linking.openURL(getServerConfig('directSupportUrl')).catch((err) => console.log(err)); + Linking.openURL(config.get('directSupportUrl')).catch((err) => console.log(err)); }, []); const handleResetWallet = useCallback(() => { @@ -139,14 +135,27 @@ export const Settings: FC = () => { text: t('settings_reset_alert_button'), style: 'destructive', onPress: () => { - if (showNotifications) { - notifications.unsubscribe(); - } dispatch(walletActions.cleanWallet()); }, }, ]); - }, [dispatch, t]); + }, [dispatch]); + + const handleStopWatchWallet = useCallback(() => { + Alert.alert(t('settings_delete_watch_account'), undefined, [ + { + text: t('cancel'), + style: 'cancel', + }, + { + text: t('settings_delete_watch_account_button'), + style: 'destructive', + onPress: () => { + dispatch(walletActions.cleanWallet()); + }, + }, + ]); + }, [dispatch]); const handleSubscriptions = useCallback(() => { openSubscriptions(); @@ -156,25 +165,13 @@ export const Settings: FC = () => { openNotifications(); }, []); - const versions = useMemo(() => { - return Object.keys(SelectableVersionsConfig).filter((key) => { - if (key === SelectableVersions.V4R1) { - return showV4R1; - } - return true; - }) as SelectableVersion[]; - }, [showV4R1]); - const searchEngineVariants = Object.values(SearchEngine); - const handleChangeVersion = useCallback( - (version: SelectableVersion) => { - dispatch(walletActions.switchVersion(version)); - }, - [dispatch], - ); - const handleSwitchLanguage = useCallback(() => { + if (Platform.OS === 'android') { + return openSelectLanguage(); + } + Alert.alert(t('language.language_alert.title'), undefined, [ { text: t('language.language_alert.cancel'), @@ -194,15 +191,13 @@ export const Settings: FC = () => { }, []); const handleSecurity = useCallback(() => { - MainDB.isNewSecurityFlow().then((isNew) => { - if (isNew) { - openSecurity(); - } else { - openSecurityMigration(); - } - }); + openSecurity(); }, []); + const handleBackupSettings = useCallback(() => { + nav.navigate(SettingsStackRouteNames.Backup); + }, [nav]); + const handleAppearance = useCallback(() => { openAppearance(); }, []); @@ -226,26 +221,41 @@ export const Settings: FC = () => { style: 'destructive', onPress: () => { trackEvent('delete_wallet'); - notifications.unsubscribe(); openDeleteAccountDone(); }, }, ]); }, []); - const notificationIndicator = React.useMemo(() => { - if (notificationsBadge.isVisible) { - return ( - - - - ); + const handleCustomizePress = useCallback( + () => nav.navigate(AppStackRouteNames.CustomizeWallet), + [nav], + ); + + const backupIndicator = React.useMemo(() => { + if (lastBackupAt !== null) { + return null; + } + + return ( + + + + ); + }, [lastBackupAt]); + + const accountNfts = useNftsState((s) => s.accountNfts); + + const hasDiamods = useMemo(() => { + if (!wallet || wallet.isWatchOnly) { + return false; } - return null; - }, [notificationsBadge.isVisible]); + return Object.values(accountNfts).find((nft) => + checkIsTonDiamondsNFT(mapNewNftToOldNftData(nft, wallet.address.ton.friendly)), + ); + }, [wallet, accountNfts]); - const hasDiamods = useHasDiamondsOnBalance(); const isAppearanceVisible = React.useMemo(() => { return hasDiamods && !flags.disable_apperance; }, [hasDiamods, flags.disable_apperance]); @@ -262,8 +272,21 @@ export const Settings: FC = () => { }} scrollEventThrottle={16} > + {wallet ? ( + <> + + } + /> + + + + ) : null} - {!!wallet && ( + {!!wallet && !wallet.isWatchOnly && ( { name={'ic-key-28'} /> } - title={t('settings_security')} - onPress={handleSecurity} + title={ + + + {t('settings_backup_seed')} + + {backupIndicator} + + } + onPress={handleBackupSettings} /> )} {shouldShowTokensButton && ( @@ -302,6 +332,13 @@ export const Settings: FC = () => { onPress={handleSubscriptions} /> )} + {!!wallet && wallet.notifications.isAvailable && !wallet.isTestnet && ( + } + title={t('settings_notifications')} + onPress={handleNotifications} + /> + )} {isAppearanceVisible && ( { onPress={handleAppearance} /> )} + {fiatCurrency.toUpperCase()} + } + title={t('settings_primary_currency')} + onPress={() => nav.navigate('ChooseCurrency')} + /> {isBatteryVisible && ( { onPress={handleBattery} /> )} + {!config.get('disable_holders_cards') && !!wallet && !wallet.isWatchOnly && ( + + } + title={t('settings_bank_card')} + onPress={() => nav.navigate(MainStackRouteNames.HoldersWebView)} + /> + )} - {!!wallet && showNotifications && ( + {!!wallet && tk.walletForUnlock && ( } - title={ - - - {t('settings_notifications')} - - {notificationIndicator} - + value={ + } - onPress={handleNotifications} + title={t('settings_security')} + onPress={handleSecurity} /> )} - {fiatCurrency.toUpperCase()} - } - title={t('settings_primary_currency')} - onPress={() => nav.navigate('ChooseCurrency')} - /> { onPress={handleSwitchLanguage} value={ - {t('language.list_item.value')} + {nativeLocaleNames[i18n.locale]} } - title={t('language.list_item.title')} + title={t('language.title')} /> - {!!wallet && ( - item} - width={220} - renderItem={(version) => ( - - - {SelectableVersionsConfig[version]?.label} - - - {Address.parse( - allTonAddesses[SelectableVersionsConfig[version]?.label], - { bounceable: !flags.address_style_nobounce }, - ).toShort()} - - - )} - > - - {SelectableVersionsConfig[version]?.label} - - } - title={t('settings_wallet_version')} - /> - - )} - {wallet && flags.address_style_settings ? ( + {wallet && !wallet.isWatchOnly && flags.address_style_settings ? ( @@ -471,7 +489,7 @@ export const Settings: FC = () => { } title={t('settings_rate')} /> - {!!wallet && ( + {!!wallet && !wallet.isWatchOnly && ( { {!!wallet && ( <> - - {t('settings_reset')} - + {wallet.isWatchOnly ? ( + + {t('stop_watch')} + + ) : ( + + {t('settings_reset')} + + )} @@ -551,11 +575,11 @@ const styles = Steezy.create({ marginTop: -2, marginBottom: -2, }, - notificationsTextContainer: { + backupTextContainer: { alignItems: 'center', flexDirection: 'row', }, - notificationIndicatorContainer: { + backupIndicatorContainer: { height: 24, paddingTop: 9.5, paddingBottom: 6.5, diff --git a/packages/mobile/src/core/SetupBiometry/SetupBiometry.interface.ts b/packages/mobile/src/core/SetupBiometry/SetupBiometry.interface.ts deleted file mode 100644 index 7cc7aae59..000000000 --- a/packages/mobile/src/core/SetupBiometry/SetupBiometry.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { RouteProp } from '@react-navigation/native'; -import { MainStackParamList } from '$navigation/MainStack'; - -import { MainStackRouteNames } from '$navigation'; - -export interface SetupBiometryProps { - route: RouteProp; -} diff --git a/packages/mobile/src/core/SetupBiometry/SetupBiometry.tsx b/packages/mobile/src/core/SetupBiometry/SetupBiometry.tsx deleted file mode 100644 index 4519417ff..000000000 --- a/packages/mobile/src/core/SetupBiometry/SetupBiometry.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; -import LottieView from 'lottie-react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import * as LocalAuthentication from 'expo-local-authentication'; -import { useDispatch } from 'react-redux'; -import { useRoute } from '@react-navigation/native'; - -import { SetupBiometryProps } from './SetupBiometry.interface'; -import * as S from './SetupBiometry.style'; -import { Button, NavBar, Text } from '$uikit'; -import { ns, platform } from '$utils'; -import { - openImportSetupNotifications, - openSetupNotifications, - openSetupWalletDone, - ResetPinStackRouteNames, - SetupWalletStackRouteNames, -} from '$navigation'; -import { walletActions } from '$store/wallet'; -import { t } from '@tonkeeper/shared/i18n'; -import { getPermission } from '$utils/messaging'; -import { Toast } from '$store'; -import { goBack, popToTop } from '$navigation/imperative'; -import { Steezy } from '@tonkeeper/uikit'; - -const LottieFaceId = require('$assets/lottie/faceid.json'); -const LottieTouchId = require('$assets/lottie/touchid.json'); - -export const SetupBiometry: FC = ({ route }) => { - const { pin, biometryType } = route.params; - - const routeNode = useRoute(); - const dispatch = useDispatch(); - - const { bottom: bottomInset } = useSafeAreaInsets(); - const iconRef = useRef(null); - const isTouchId = - biometryType !== LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION; - const [isLoading, setLoading] = useState(false); - - useMemo(() => { - const timer = setTimeout(() => { - iconRef.current?.play(); - }, 400); - - return () => clearTimeout(timer); - }, []); - - const doCreateWallet = useCallback( - (isBiometryEnabled: boolean) => () => { - dispatch( - walletActions.createWallet({ - isBiometryEnabled, - pin, - onDone: async () => { - if (routeNode.name === ResetPinStackRouteNames.SetupBiometry) { - popToTop(); - Toast.success(); - setTimeout(() => goBack(), 20); - } else { - const hasNotificationPermission = await getPermission(); - if (hasNotificationPermission) { - openSetupWalletDone(); - } else { - if (routeNode.name === SetupWalletStackRouteNames.SetupBiometry) { - openSetupNotifications(); - } else { - openImportSetupNotifications(); - } - } - } - }, - onFail: () => { - setLoading(false); - }, - }), - ); - }, - [dispatch, pin], - ); - - const biometryNameGenitive = useMemo(() => { - return isTouchId - ? t(`platform.${platform}.fingerprint_genitive`) - : t(`platform.${platform}.face_recognition_genitive`); - }, [t, isTouchId]); - - const biometryName = useMemo(() => { - return isTouchId - ? t(`platform.${platform}.fingerprint`) - : t(`platform.${platform}.face_recognition`); - }, [t, isTouchId]); - - const handleEnable = useCallback(() => { - setLoading(true); - doCreateWallet(true)(); - }, [doCreateWallet]); - - return ( - <> - - {t('later')} - - } - /> - - - - - {t('setup_biometry_title', { biometryType: biometryNameGenitive })} - - - - {t('setup_biometry_caption', { - biometryType: isTouchId - ? t(`platform.${platform}.capitalized_fingerprint`) - : t(`platform.${platform}.capitalized_face_recognition`), - })} - - - - - - - - - ); -}; - -const styles = Steezy.create({ - lottieIcon: { - width: 160, - height: 160, - }, -}); diff --git a/packages/mobile/src/core/SetupNotifications/SetupNotifications.style.ts b/packages/mobile/src/core/SetupNotifications/SetupNotifications.style.ts deleted file mode 100644 index 244763887..000000000 --- a/packages/mobile/src/core/SetupNotifications/SetupNotifications.style.ts +++ /dev/null @@ -1,40 +0,0 @@ -import styled from '$styled'; -import { nfs, ns } from '$utils'; - -export const Wrap = styled.View` - flex: 1; - padding: ${ns(32)}px; - padding-top: 0; -`; - -export const Content = styled.View` - flex: 1; - align-items: center; - justify-content: center; -`; - -export const IconWrap = styled.View` - margin-bottom: ${ns(16)}px; -`; - -export const Title = styled.Text` - font-family: ${({ theme }) => theme.font.semiBold}; - color: ${({ theme }) => theme.colors.foregroundPrimary}; - font-size: ${nfs(24)}px; - line-height: 32px; - text-align: center; -`; - -export const Caption = styled.Text` - font-family: ${({ theme }) => theme.font.medium}; - color: ${({ theme }) => theme.colors.foregroundSecondary}; - font-size: ${nfs(16)}px; - line-height: 24px; - margin-top: ${ns(4)}px; - text-align: center; -`; - -export const Footer = styled.View` - flex: 0 0 auto; - padding-top: ${ns(16)}px; -`; diff --git a/packages/mobile/src/core/SetupNotifications/SetupNotifications.tsx b/packages/mobile/src/core/SetupNotifications/SetupNotifications.tsx index 4a33f8fe8..165f90310 100644 --- a/packages/mobile/src/core/SetupNotifications/SetupNotifications.tsx +++ b/packages/mobile/src/core/SetupNotifications/SetupNotifications.tsx @@ -1,74 +1,98 @@ -import React from 'react'; -import { Button, Icon, Screen, Spacer, Text } from '$uikit'; -import * as S from '$core/SetupNotifications/SetupNotifications.style'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useDispatch } from 'react-redux'; +import React, { useCallback } from 'react'; import { t } from '@tonkeeper/shared/i18n'; import { openSetupWalletDone } from '$navigation'; import { ns } from '$utils'; import { debugLog } from '$utils/debugLog'; -import { useNotifications } from '$hooks/useNotifications'; -import { saveDontShowReminderNotifications } from '$utils/messaging'; import { Toast } from '$store'; +import { tk } from '$wallet'; +import { RouteProp } from '@react-navigation/native'; +import { + ImportWalletStackParamList, + ImportWalletStackRouteNames, +} from '$navigation/ImportWalletStack/types'; +import { Button, Icon, Screen, Spacer, Steezy, Text, View } from '@tonkeeper/uikit'; +import { delay } from '@tonkeeper/core'; + +interface Props { + route: RouteProp; +} + +export const SetupNotifications: React.FC = (props) => { + const { identifiers } = props.route.params; -export const SetupNotifications: React.FC = () => { const [loading, setLoading] = React.useState(false); - const notifications = useNotifications(); - const safeArea = useSafeAreaInsets(); - const dispatch = useDispatch(); - React.useEffect(() => { - saveDontShowReminderNotifications(); - }, []); + const handleDone = useCallback(() => { + openSetupWalletDone(identifiers); + }, [identifiers]); const handleEnableNotifications = React.useCallback(async () => { try { setLoading(true); - await notifications.subscribe(); - openSetupWalletDone(); + + if (identifiers.length > 1) { + await Promise.race([tk.enableNotificationsForAll(identifiers), delay(10000)]); + } else { + await Promise.race([tk.wallet.notifications.subscribe(), delay(10000)]); + } + + handleDone(); } catch (err) { setLoading(false); Toast.fail(err?.massage); debugLog('[SetupNotifications]:', err); } - }, []); + }, [handleDone, identifiers]); return ( - openSetupWalletDone()} - > - {t('later')} - + onPress={handleDone} + /> } /> - - - - - - + + + + + + + {t('setup_notifications_title')} - + {t('setup_notifications_caption')} - - - - - + + + + + + } + /> + + + + + {t('setup_biometry_title', { + biometryType: getBiometryName(biometry.type, { genitive: true }), + })} + + + + {t('setup_biometry_caption', { + biometryType: getBiometryName(biometry.type, { capitalized: true }), + })} + + + + + + + + + ); +}; + +const styles = Steezy.create({ + lottieIcon: { + width: 160, + height: 160, + }, +}); diff --git a/packages/mobile/src/core/SetupBiometry/SetupBiometry.style.ts b/packages/mobile/src/screens/ChangePinBiometry/SetupBiometry.style.ts similarity index 100% rename from packages/mobile/src/core/SetupBiometry/SetupBiometry.style.ts rename to packages/mobile/src/screens/ChangePinBiometry/SetupBiometry.style.ts diff --git a/packages/mobile/src/screens/ChangePinBiometry/index.ts b/packages/mobile/src/screens/ChangePinBiometry/index.ts new file mode 100644 index 000000000..1bf89232a --- /dev/null +++ b/packages/mobile/src/screens/ChangePinBiometry/index.ts @@ -0,0 +1 @@ +export * from './ChangePinBiometry'; diff --git a/packages/mobile/src/screens/ChooseWallets/ChooseWallets.tsx b/packages/mobile/src/screens/ChooseWallets/ChooseWallets.tsx new file mode 100644 index 000000000..59f73b1c1 --- /dev/null +++ b/packages/mobile/src/screens/ChooseWallets/ChooseWallets.tsx @@ -0,0 +1,111 @@ +import { + ImportWalletStackParamList, + ImportWalletStackRouteNames, +} from '$navigation/ImportWalletStack/types'; +import { Checkbox } from '$uikit'; +import { WalletContractVersion } from '$wallet/WalletTypes'; +import { t } from '@tonkeeper/shared/i18n'; +import { Button, List, Screen, Spacer, Steezy, Text, isAndroid } from '@tonkeeper/uikit'; +import { FC, useCallback, useState } from 'react'; +import { RouteProp } from '@react-navigation/native'; +import { Address } from '@tonkeeper/shared/Address'; +import { formatter } from '@tonkeeper/shared/formatter'; +import { useImportWallet } from '$hooks/useImportWallet'; + +export const ChooseWallets: FC<{ + route: RouteProp; +}> = (props) => { + const { mnemonic, lockupConfig, isTestnet, walletsInfo, isMigration } = + props.route.params; + + const doImportWallet = useImportWallet(); + + const [selectedVersions, setSelectedVersions] = useState( + walletsInfo + .filter((item) => item.balance > 0 || item.tokens) + .map((item) => item.version), + ); + const [loading, setLoading] = useState(false); + + const toggleVersion = useCallback((version: WalletContractVersion) => { + setSelectedVersions((s) => + s.includes(version) ? s.filter((item) => item !== version) : [...s, version], + ); + }, []); + + const handleContinue = useCallback(async () => { + if (selectedVersions.length === 0) { + return; + } + try { + setLoading(true); + await doImportWallet( + mnemonic, + lockupConfig, + selectedVersions, + isTestnet, + isMigration, + ); + } catch { + } finally { + setLoading(false); + } + }, [doImportWallet, isMigration, isTestnet, lockupConfig, mnemonic, selectedVersions]); + + const tokensText = `, ${t('choose_wallets.tokens')}`; + + return ( + + + + + + + {t('choose_wallets.title')} + + + + {t('choose_wallets.subtitle')} + + + + {walletsInfo.map((walletInfo) => ( + {}} + disabled={isAndroid} + /> + } + onPress={() => toggleVersion(walletInfo.version)} + /> + ))} + + + + + + } + /> + + + {t('access_confirmation_title')} + + + + + + + + ); +}; diff --git a/packages/mobile/src/screens/MigrationPasscode/index.ts b/packages/mobile/src/screens/MigrationPasscode/index.ts new file mode 100644 index 000000000..af82262a4 --- /dev/null +++ b/packages/mobile/src/screens/MigrationPasscode/index.ts @@ -0,0 +1 @@ +export * from './MigrationPasscode'; diff --git a/packages/mobile/src/screens/MigrationStartScreen/MigrationStartScreen.tsx b/packages/mobile/src/screens/MigrationStartScreen/MigrationStartScreen.tsx new file mode 100644 index 000000000..cf4db510e --- /dev/null +++ b/packages/mobile/src/screens/MigrationStartScreen/MigrationStartScreen.tsx @@ -0,0 +1,105 @@ +import { Screen, View, Steezy, Spacer, Button, Icon, Text, ns } from '@tonkeeper/uikit'; +import { memo, useCallback } from 'react'; +import { t } from '@tonkeeper/shared/i18n'; +import { Alert } from 'react-native'; +import { tk } from '$wallet'; +import { getBiometryName } from '$utils'; +import { useNavigation } from '@tonkeeper/router'; +import { MigrationStackRouteNames } from '$navigation/MigrationStack/types'; +import { useMigration } from '$hooks/useMigration'; + +export const MigrationStartScreen = memo(() => { + const nav = useNavigation(); + const { getMnemonicWithBiometry } = useMigration(); + + const handleLogout = useCallback(() => { + Alert.alert(t('settings_reset_alert_title'), t('settings_reset_alert_caption'), [ + { + text: t('cancel'), + style: 'cancel', + }, + { + text: t('settings_reset_alert_button'), + style: 'destructive', + onPress: () => { + tk.setMigrated(); + }, + }, + ]); + }, []); + + const handlePasscodePress = useCallback(() => { + nav.navigate(MigrationStackRouteNames.Passcode); + }, [nav]); + + const handleBiometryPress = useCallback(async () => { + try { + const mnemonic = await getMnemonicWithBiometry(); + + nav.navigate(MigrationStackRouteNames.CreatePasscode, { mnemonic }); + } catch {} + }, [getMnemonicWithBiometry, nav]); + + return ( + + + } + /> + + + + + + + {t('migration.title')} + + + + {t('migration.subtitle')} + + + + + - - )} ); }); - -const styles = Steezy.create({ - tonkensEdit: { - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, -}); diff --git a/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx b/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx index b25a63d9f..41106cca4 100644 --- a/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx +++ b/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx @@ -1,8 +1,8 @@ -import React, { memo, useCallback, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { t } from '@tonkeeper/shared/i18n'; import { Screen, - Spacer, + Spacer as SpacerView, SpacerSizes, View, List, @@ -11,29 +11,30 @@ import { } from '@tonkeeper/uikit'; import { Steezy } from '$styles'; import { RefreshControl } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; import { openJetton, openTonInscription } from '$navigation'; -import { walletActions } from '$store/wallet'; import { Rate } from '../hooks/useBalance'; import { ListItemRate } from './ListItemRate'; import { TonIcon, TonIconProps } from '@tonkeeper/uikit'; import { CryptoCurrencies, LockupNames } from '$shared/constants'; import { NFTsList } from './NFTsList'; -import { TokenPrice, useTokenPrice } from '$hooks/useTokenPrice'; +import { TokenPrice } from '$hooks/useTokenPrice'; import { useTheme } from '$hooks/useTheme'; import { ListSeparator } from '$uikit/List/ListSeparator'; import { StakingWidget } from './StakingWidget'; import { HideableAmount } from '$core/HideableAmount/HideableAmount'; import { openWallet } from '$core/Wallet/ToncoinScreen'; import { TronBalance } from '@tonkeeper/core/src/TronAPI/TronAPIGenerated'; -import { fiatCurrencySelector } from '$store/main'; -import { FiatCurrencies } from '@tonkeeper/core'; -import { useTonInscriptions } from '@tonkeeper/shared/query/hooks/useTonInscriptions'; +import { WalletCurrency } from '@tonkeeper/core'; import { formatter } from '@tonkeeper/shared/formatter'; import { Text } from '@tonkeeper/uikit'; import { JettonVerification } from '$store/models'; -import { ListItemProps } from '$uikit/List/ListItem'; -import { config } from '@tonkeeper/shared/config'; +import { config } from '$config'; +import { useWallet, useWalletCurrency } from '@tonkeeper/shared/hooks'; +import { CardsWidget } from '$components'; +import { InscriptionBalance } from '@tonkeeper/core/src/TonAPI'; +import { ListItemProps } from '@tonkeeper/uikit/src/components/List/ListItem'; +import { FinishSetupList } from './FinishSetupList'; +import BigNumber from 'bignumber.js'; enum ContentType { Token, @@ -41,6 +42,8 @@ enum ContentType { Spacer, NFTCardsRow, Staking, + Cards, + Setup, } type TokenItem = { @@ -75,9 +78,27 @@ type NFTCardsRowItem = { type StakingItem = { key: string; type: ContentType.Staking; + isWatchOnly: boolean; + showBuyButton: boolean; +}; + +type CardsItem = { + key: string; + type: ContentType.Cards; }; -type Content = TokenItem | SpacerItem | NFTCardsRowItem | StakingItem; +type SetupItem = { + key: string; + type: ContentType.Setup; +}; + +type Content = + | TokenItem + | SpacerItem + | NFTCardsRowItem + | StakingItem + | CardsItem + | SetupItem; const RenderItem = ({ item }: { item: Content }) => { switch (item.type) { @@ -154,19 +175,30 @@ const RenderItem = ({ item }: { item: Content }) => { ); case ContentType.Spacer: - return ; + return ; case ContentType.NFTCardsRow: return ; case ContentType.Staking: - return ; + return ( + + ); + case ContentType.Cards: + return ; + case ContentType.Setup: + return ; } }; interface BalancesListProps { + currency: WalletCurrency; tokens: any; // TODO: balance: any; // TODO: tonPrice: TokenPrice; nfts?: any; // TODO: + inscriptions: InscriptionBalance[]; tronBalances?: TronBalance[]; handleRefresh: () => void; isRefreshing: boolean; @@ -176,34 +208,29 @@ interface BalancesListProps { export const WalletContentList = memo( ({ + currency, tokens, balance, tonPrice, nfts, handleRefresh, isRefreshing, + inscriptions, isFocused, ListHeaderComponent, - tronBalances, }) => { const theme = useTheme(); - const dispatch = useDispatch(); - - const fiatCurrency = useSelector(fiatCurrencySelector); - const shouldShowTonDiff = fiatCurrency !== FiatCurrencies.Ton; - const inscriptions = useTonInscriptions(); - - const handleMigrate = useCallback( - (fromVersion: string) => () => { - dispatch( - walletActions.openMigration({ - isTransfer: true, - fromVersion, - }), - ); - }, - [dispatch], - ); + + const fiatCurrency = useWalletCurrency(); + const shouldShowTonDiff = fiatCurrency !== WalletCurrency.TON; + + const wallet = useWallet(); + const isWatchOnly = wallet && wallet.isWatchOnly; + const isLockup = wallet && wallet.isLockup; + const identifier = wallet.identifier; + const showStaking = isWatchOnly ? balance.staking.amount.nano !== '0' : true; + const showBuyButton = + !isLockup && new BigNumber(balance.ton.amount.nano).isLessThan(50); const data = useMemo(() => { const content: Content[] = []; @@ -222,28 +249,13 @@ export const WalletContentList = memo( price: tonPrice.formatted.fiat ?? '-', trend: tonPrice.fiatDiff.trend, }, + isLast: !showStaking, }); - content.push( - ...balance.oldVersions.map((item) => ({ - type: ContentType.Token, - key: 'old_' + item.version, - onPress: handleMigrate(item.version), - title: t('wallet.old_wallet_title'), - tonIcon: { transparent: true }, - value: item.amount.formatted, - subvalue: item.amount.fiat, - rate: { - percent: shouldShowTonDiff ? tonPrice.fiatDiff.percent : '', - price: tonPrice.formatted.fiat ?? '-', - trend: tonPrice.fiatDiff.trend, - }, - })), - ); - if (balance.lockup.length > 0) { content.push( ...balance.lockup.map((item) => ({ + key: item.type, type: ContentType.Token, tonIcon: { locked: true }, title: LockupNames[item.type], @@ -254,50 +266,35 @@ export const WalletContentList = memo( ); } - // if (tronBalances && tronBalances.length > 0) { - // content.push( - // ...(tronBalances as any).map((item) => { - // const amount = formatter.fromNano(item.weiAmount, item.token.decimals); - // const fiatAmount = formatter.format(usdtRate.fiat * parseFloat(amount), { - // currency: fiatCurrency - // }); - // const fiatPrice = formatter.format(usdtRate.fiat, { - // currency: fiatCurrency - // }); - - // return { - // onPress: () => openTronToken(item), - // type: ContentType.Token, - // picture: item.token.image, - // title: ( - // - // {item.token.symbol} - // - // - // TRC20 - // - // - // - // ), - // value: amount, - // subvalue: fiatAmount, - // subtitle: fiatPrice, - // }; - // }), - // ); - // } + if (showStaking) { + content.push({ + key: 'staking', + type: ContentType.Staking, + isWatchOnly, + showBuyButton, + }); + } - content.push({ - key: 'staking', - type: ContentType.Staking, - }); + if (!isWatchOnly) { + content.push({ + key: `setup_${identifier}`, + type: ContentType.Setup, + }); + } content.push({ - key: 'spacer_staking', + key: 'ton_section_spacer', type: ContentType.Spacer, bottom: 32, }); + if (!config.get('disable_holders_cards') && !isWatchOnly) { + content.push({ + key: 'cards', + type: ContentType.Cards, + }); + } + content.push( ...tokens.list.map((item, index) => ({ key: 'token_' + item.address.rawAddress, @@ -327,9 +324,9 @@ export const WalletContentList = memo( })), ); - if (inscriptions?.items?.length > 0) { + if (inscriptions?.length > 0) { content.push( - ...inscriptions.items.map((item) => ({ + ...inscriptions.map((item) => ({ key: 'inscriptions' + item.ticker, onPress: () => openTonInscription({ ticker: item.ticker, type: item.type }), type: ContentType.Token, @@ -337,6 +334,10 @@ export const WalletContentList = memo( picture: DEFAULT_TOKEN_LOGO, title: item.ticker, value: formatter.formatNano(item.balance, { decimals: item.decimals }), + subvalue: formatter.format('0', { currency, currencySeparator: 'wide' }), + rate: { + price: formatter.format('0', { currency, currencySeparator: 'wide' }), + }, })), ); } @@ -374,7 +375,19 @@ export const WalletContentList = memo( }); return content; - }, [balance, handleMigrate, nfts, tokens.list, tonPrice, tronBalances]); + }, [ + balance, + shouldShowTonDiff, + tonPrice, + showStaking, + isWatchOnly, + tokens.list, + inscriptions, + nfts, + showBuyButton, + identifier, + currency, + ]); const ListComponent = nfts ? Screen.FlashList : PagerView.FlatList; diff --git a/packages/mobile/src/tabs/Wallet/components/WalletSelector.tsx b/packages/mobile/src/tabs/Wallet/components/WalletSelector.tsx new file mode 100644 index 000000000..6a4f6329a --- /dev/null +++ b/packages/mobile/src/tabs/Wallet/components/WalletSelector.tsx @@ -0,0 +1,80 @@ +import { useWallet } from '@tonkeeper/shared/hooks'; +import { + Flash, + Haptics, + Icon, + Spacer, + Steezy, + Text, + TouchableOpacity, + View, + deviceWidth, + getWalletColorHex, + isAndroid, +} from '@tonkeeper/uikit'; +import React, { FC, memo, useCallback } from 'react'; +import { Text as RNText } from 'react-native'; +import { useNavigation } from '@tonkeeper/router'; +import { FlashCountKeys, useFlashCount } from '$store'; +import { tk } from '$wallet'; + +const WalletSelectorComponent: FC = () => { + const wallet = useWallet(); + const nav = useNavigation(); + const [flashShownCount, disableFlash] = useFlashCount(FlashCountKeys.MultiWallet); + + const handlePress = useCallback(() => { + disableFlash(); + Haptics.selection(); + nav.openModal('/switch-wallet'); + }, [disableFlash, nav]); + + return ( + + + = 3} + > + {wallet.config.emoji} + + + + {wallet.config.name} + + + + + + + + ); +}; + +export const WalletSelector = memo(WalletSelectorComponent); + +const styles = Steezy.create({ + container: { alignItems: 'center' }, + selectorContainer: { + height: 40, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 10, + paddingRight: 12, + borderRadius: 20, + overflow: 'hidden', + }, + nameContainer: { + maxWidth: deviceWidth - 180, + }, + icon: { + opacity: 0.64, + }, + emoji: { + fontSize: isAndroid ? 17 : 20, + marginTop: isAndroid ? -1 : 1, + }, +}); diff --git "a/packages/mobile/src/tabs/Wallet/components/\320\241onfirmRenewAllDomains.tsx" "b/packages/mobile/src/tabs/Wallet/components/\320\241onfirmRenewAllDomains.tsx" index 2b11d7b2e..046605a66 100644 --- "a/packages/mobile/src/tabs/Wallet/components/\320\241onfirmRenewAllDomains.tsx" +++ "b/packages/mobile/src/tabs/Wallet/components/\320\241onfirmRenewAllDomains.tsx" @@ -9,7 +9,6 @@ import * as S from '../../../core/ModalContainer/NFTOperations/NFTOperations.sty import { useExpiringDomains } from '$store/zustand/domains/useExpiringDomains'; import { formatter } from '$utils/formatter'; import { useFiatValue } from '$hooks/useFiatValue'; -import { useWallet } from '$hooks/useWallet'; import { CryptoCurrencies, Decimals } from '$shared/constants'; import { copyText } from '$hooks/useCopyText'; import { useUnlockVault } from '$core/ModalContainer/NFTOperations/useUnlockVault'; @@ -19,15 +18,15 @@ import { debugLog } from '$utils/debugLog'; import { Toast } from '$store/zustand/toast'; import { Ton } from '$libs/Ton'; import TonWeb from 'tonweb'; -import { Tonapi } from '$libs/Tonapi'; -import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; import { checkIsInsufficient, openInsufficientFundsModal, } from '$core/ModalContainer/InsufficientFunds/InsufficientFunds'; import BigNumber from 'bignumber.js'; -import { tk, tonapi } from '@tonkeeper/shared/tonkeeper'; +import { tk } from '$wallet'; import { Address } from '@tonkeeper/core'; +import { walletWalletSelector } from '$store/wallet'; enum States { INITIAL, @@ -43,8 +42,7 @@ export const СonfirmRenewAllDomains = memo((props) => { const remove = useExpiringDomains((state) => state.actions.remove); const unlock = useUnlockVault(); const [current, setCurrent] = useState(0); - const wallet = useWallet(); - const dispatch = useDispatch(); + const wallet = useSelector(walletWalletSelector)!; const [count] = useState(domains.length); const [amount] = useState(0.02 * count); @@ -60,7 +58,7 @@ export const СonfirmRenewAllDomains = memo((props) => { const totalAmount = new BigNumber(amount) .multipliedBy(new BigNumber(domains.length)) .toString(); - const checkResult = await checkIsInsufficient(totalAmount); + const checkResult = await checkIsInsufficient(totalAmount, tk.wallet); if (checkResult.insufficient) { return openInsufficientFundsModal({ totalAmount, balance: checkResult.balance }); } @@ -111,7 +109,10 @@ export const СonfirmRenewAllDomains = memo((props) => { const queryMsg = await tx.getQuery(); const boc = Base64.encodeBytes(await queryMsg.toBoc(false)); - await tonapi.blockchain.sendBlockchainMessage({ boc }, { format: 'text' }); + await tk.wallet.tonapi.blockchain.sendBlockchainMessage( + { boc }, + { format: 'text' }, + ); tk.wallet.activityList.reload(); await delay(15000); diff --git a/packages/mobile/src/tabs/Wallet/hooks/useBalance.ts b/packages/mobile/src/tabs/Wallet/hooks/useBalance.ts index 9c5ab4c7e..26878e686 100644 --- a/packages/mobile/src/tabs/Wallet/hooks/useBalance.ts +++ b/packages/mobile/src/tabs/Wallet/hooks/useBalance.ts @@ -1,21 +1,18 @@ import { CryptoCurrencies, Decimals } from '$shared/constants'; -import { useSelector } from 'react-redux'; -import { fiatCurrencySelector } from '$store/main'; import { useGetTokenPrice, useTokenPrice } from '$hooks/useTokenPrice'; import { useCallback, useMemo } from 'react'; -import { - isLockupWalletSelector, - walletBalancesSelector, - walletOldBalancesSelector, - walletVersionSelector, -} from '$store/wallet'; import { Ton } from '$libs/Ton'; import BigNumber from 'bignumber.js'; import { formatter } from '$utils/formatter'; -import { getStakingJettons, useStakingStore } from '$store'; -import { shallow } from 'zustand/shallow'; -import { jettonsBalancesSelector } from '$store/jettons'; +import { getStakingJettons } from '@tonkeeper/shared/utils/staking'; import { Address } from '@tonkeeper/core'; +import { + useBalancesState, + useJettons, + useStakingState, + useWallet, + useWalletCurrency, +} from '@tonkeeper/shared/hooks'; export type Rate = { percent: string; @@ -26,7 +23,7 @@ export type Rate = { // TODO: rewrite const useAmountToFiat = () => { const tonPrice = useTokenPrice(CryptoCurrencies.Ton); - const fiatCurrency = useSelector(fiatCurrencySelector); + const fiatCurrency = useWalletCurrency(); const amountToFiat = useCallback( (amount: string, fiatAmountToSum?: number) => { @@ -46,9 +43,9 @@ const useAmountToFiat = () => { }; const useStakingBalance = () => { - const _stakingBalance = useStakingStore((s) => s.stakingBalance, shallow); - const stakingJettons = useStakingStore(getStakingJettons, shallow); - const jettonBalances = useSelector(jettonsBalancesSelector); + const _stakingBalance = useStakingState((s) => s.stakingBalance); + const stakingJettons = useStakingState(getStakingJettons); + const { jettonBalances } = useJettons(); const amountToFiat = useAmountToFiat(); const getTokenPrice = useGetTokenPrice(); @@ -79,81 +76,53 @@ const useStakingBalance = () => { }; export const useBalance = (tokensTotal: number) => { - const balances = useSelector(walletBalancesSelector); - const walletVersion = useSelector(walletVersionSelector); - const oldWalletBalances = useSelector(walletOldBalancesSelector); - const isLockup = useSelector(isLockupWalletSelector); + const balances = useBalancesState(); + const wallet = useWallet(); const amountToFiat = useAmountToFiat(); - const getTokenPrice = useGetTokenPrice(); const stakingBalance = useStakingBalance(); - const oldVersions = useMemo(() => { - if (isLockup) { - return []; - } - - return oldWalletBalances.reduce((acc, item) => { - if (walletVersion && walletVersion <= item.version) { - return acc; - } - - const balance = Ton.fromNano(item.balance); - - acc.push({ - version: item.version, - amount: { - value: balance, - formatted: formatter.format(balance), - fiat: amountToFiat(balance), - }, - }); - - return acc; - }, [] as any); - }, [isLockup, oldWalletBalances, walletVersion, amountToFiat]); - const lockup = useMemo(() => { - const lockupList: { type: CryptoCurrencies; amount: string }[] = []; - const restricted = balances[CryptoCurrencies.TonRestricted]; - const locked = balances[CryptoCurrencies.TonLocked]; - - if (restricted) { - lockupList.push({ - type: CryptoCurrencies.TonRestricted, - amount: restricted, - }); - } + const lockupList: { + type: CryptoCurrencies; + amount: { + nano: string; + fiat: string; + formatted: string; + }; + }[] = []; - if (locked) { - lockupList.push({ - type: CryptoCurrencies.TonLocked, - amount: locked, - }); + if (!wallet.isLockup) { + return lockupList; } - return lockupList.map((item) => { - const amount = balances[item.type]; + lockupList.push({ + type: CryptoCurrencies.TonRestricted, + amount: { + nano: balances.tonRestricted, + formatted: formatter.format(balances.tonRestricted), + fiat: amountToFiat(balances.tonRestricted), + }, + }); - return { - type: item.type, - amount: { - nano: item.amount, - formatted: formatter.format(amount), - fiat: amountToFiat(amount), - }, - }; + lockupList.push({ + type: CryptoCurrencies.TonLocked, + amount: { + nano: balances.tonLocked, + formatted: formatter.format(balances.tonLocked), + fiat: amountToFiat(balances.tonLocked), + }, }); - }, [balances, getTokenPrice]); - const ton = useMemo(() => { - const balance = balances[CryptoCurrencies.Ton] ?? '0'; + return lockupList; + }, [amountToFiat, balances, wallet.isLockup]); - const formatted = formatter.format(balance); + const ton = useMemo(() => { + const formatted = formatter.format(balances.ton); return { amount: { - nano: balance, - fiat: amountToFiat(balance), + nano: balances.ton, + fiat: amountToFiat(balances.ton), formatted, }, }; @@ -179,11 +148,11 @@ export const useBalance = (tokensTotal: number) => { return useMemo( () => ({ - oldVersions, lockup, total, ton, + staking: stakingBalance, }), - [oldVersions, lockup, total, ton], + [lockup, total, ton, stakingBalance], ); }; diff --git a/packages/mobile/src/tabs/Wallet/hooks/useInternalNotifications.ts b/packages/mobile/src/tabs/Wallet/hooks/useInternalNotifications.ts index 0fcc814ef..5aed6aa05 100644 --- a/packages/mobile/src/tabs/Wallet/hooks/useInternalNotifications.ts +++ b/packages/mobile/src/tabs/Wallet/hooks/useInternalNotifications.ts @@ -1,11 +1,9 @@ import { usePrevious } from '$hooks/usePrevious'; -import { isServerConfigLoaded } from '$shared/constants'; import { mainActions, mainSelector } from '$store/main'; -import { walletActions, walletWalletSelector } from '$store/wallet'; import { InternalNotificationProps } from '$uikit/InternalNotification/InternalNotification.interface'; import { useNetInfo } from '@react-native-community/netinfo'; import { MainDB } from '$database'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Linking } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import { t } from '@tonkeeper/shared/i18n'; @@ -13,27 +11,25 @@ import { useAddressUpdateStore } from '$store'; import { useFlag } from '$utils/flags'; import { useNavigation } from '@tonkeeper/router'; import { MainStackRouteNames } from '$navigation'; +import { config } from '$config'; +import { useWallet } from '@tonkeeper/shared/hooks'; +import { tk } from '$wallet'; export const useInternalNotifications = () => { const dispatch = useDispatch(); - const isConfigError = !isServerConfigLoaded(); const [isNoSignalDismissed, setNoSignalDismissed] = useState(false); const netInfo = useNetInfo(); const prevNetInfo = usePrevious(netInfo); - const wallet = useSelector(walletWalletSelector); + const wallet = useWallet(); const nav = useNavigation(); - const handleRefresh = useCallback(() => { - dispatch(walletActions.refreshBalancesPage(true)); - }, [dispatch]); - useEffect(() => { if (netInfo.isConnected && prevNetInfo.isConnected === false) { dispatch(mainActions.dismissBadHosts()); - handleRefresh(); + tk.wallets.forEach((item) => item.reload()); } - }, [netInfo.isConnected, prevNetInfo.isConnected, handleRefresh, dispatch]); + }, [netInfo.isConnected, prevNetInfo.isConnected, dispatch]); const { badHosts, @@ -50,7 +46,7 @@ export const useInternalNotifications = () => { const notifications = useMemo(() => { const result: InternalNotificationProps[] = []; - if (isConfigError) { + if (!config.isLoaded) { result.push({ title: t('notify_no_signal_title'), caption: t('notify_no_signal_caption'), @@ -140,7 +136,6 @@ export const useInternalNotifications = () => { return result; }, [ - isConfigError, netInfo.isConnected, badHosts, isBadHostsDismissed, diff --git a/packages/mobile/src/tabs/Wallet/hooks/useNFTs.ts b/packages/mobile/src/tabs/Wallet/hooks/useNFTs.ts deleted file mode 100644 index 322a3a104..000000000 --- a/packages/mobile/src/tabs/Wallet/hooks/useNFTs.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { nftsSelector } from "$store/nfts"; -import { useSelector } from "react-redux"; - -export const useNFTs = () => { - const { myNfts } = useSelector(nftsSelector); - - const nfts = Object.values(myNfts).map((item) => { - return item; - }); - - return nfts; -}; \ No newline at end of file diff --git a/packages/mobile/src/tabs/Wallet/hooks/useTokens.ts b/packages/mobile/src/tabs/Wallet/hooks/useTokens.ts index e7c21ac56..f09e2e94d 100644 --- a/packages/mobile/src/tabs/Wallet/hooks/useTokens.ts +++ b/packages/mobile/src/tabs/Wallet/hooks/useTokens.ts @@ -38,7 +38,6 @@ export const useTonkens = (): { total: { fiat: number; }; - canEdit: boolean; } => { const { enabled: jettonBalances } = useJettonBalances(); const getTokenPrice = useGetTokenPrice(); diff --git a/packages/mobile/src/tabs/useWallet.ts b/packages/mobile/src/tabs/useWallet.ts deleted file mode 100644 index af783ba40..000000000 --- a/packages/mobile/src/tabs/useWallet.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { WalletAddress } from '@tonkeeper/core/src/Wallet'; -import { tk } from '@tonkeeper/shared/tonkeeper'; - -export const useWallet = (): { address: WalletAddress } => { - if (tk.wallet?.address) { - return { - address: tk.wallet.address, - }; - } - - return { - address: { - tron: '', - ton: { - friendly: '', - short: '', - raw: '', - }, - }, - }; -}; diff --git a/packages/mobile/src/tonconnect/ConnectReplyBuilder.ts b/packages/mobile/src/tonconnect/ConnectReplyBuilder.ts index 686f0e9ad..3db9575d2 100644 --- a/packages/mobile/src/tonconnect/ConnectReplyBuilder.ts +++ b/packages/mobile/src/tonconnect/ConnectReplyBuilder.ts @@ -107,6 +107,7 @@ export class ConnectReplyBuilder { privateKey: Uint8Array, publicKey: Uint8Array, walletStateInit: string, + isTestnet: boolean, ): ConnectItemReply[] { const address = new TonWeb.utils.Address(addr).toString(false, true, true); @@ -116,7 +117,7 @@ export class ConnectReplyBuilder { return { name: 'ton_addr', address, - network: ConnectReplyBuilder.getNetwork(), + network: isTestnet ? CHAIN.TESTNET : CHAIN.MAINNET, publicKey: Buffer.from(publicKey).toString('hex'), walletStateInit, }; @@ -146,7 +147,7 @@ export class ConnectReplyBuilder { { name: 'ton_addr', address, - network: ConnectReplyBuilder.getNetwork(), + network: getChainName() === 'mainnet' ? CHAIN.MAINNET : CHAIN.TESTNET, publicKey: Buffer.from(publicKey).toString('hex'), walletStateInit, }, diff --git a/packages/mobile/src/tonconnect/TonConnect.ts b/packages/mobile/src/tonconnect/TonConnect.ts index 7ed7fcd55..1d71495aa 100644 --- a/packages/mobile/src/tonconnect/TonConnect.ts +++ b/packages/mobile/src/tonconnect/TonConnect.ts @@ -39,8 +39,9 @@ import { ConnectReplyBuilder } from './ConnectReplyBuilder'; import { TCEventID } from './EventID'; import { DAppManifest } from './models'; import { SendTransactionError } from './SendTransactionError'; +import { tk } from '$wallet'; import { TonConnectRemoteBridge } from './TonConnectRemoteBridge'; -import messaging from '@react-native-firebase/messaging'; +import { WithWalletIdentifier } from '$wallet/WalletTypes'; class TonConnectService { checkProtocolVersionCapability(protocolVersion: number) { @@ -113,18 +114,21 @@ class TonConnectService { const manifest = await this.getManifest(request); try { - const { address, replyItems, notificationsEnabled } = + const { address, replyItems, notificationsEnabled, walletIdentifier } = await new Promise((resolve, reject) => openTonConnect({ protocolVersion: protocolVersion as 2, manifest, replyBuilder: new ConnectReplyBuilder(request, manifest), requestPromise: { resolve, reject }, - hideImmediately: !!webViewUrl, + isInternalBrowser: !!webViewUrl, }), ); + const wallet = tk.wallets.get(walletIdentifier)!; + saveAppConnection( + wallet.isTestnet, address, { name: manifest.name, @@ -143,7 +147,7 @@ class TonConnectService { ); if (notificationsEnabled) { - enableNotifications(address, manifest.url, clientSessionId); + enableNotifications(wallet.isTestnet, address, manifest.url, clientSessionId); } return { @@ -222,6 +226,7 @@ class TonConnectService { ) ) { saveAppConnection( + tk.wallet.isTestnet, currentWalletAddress, { name: connectedApp.name, @@ -258,12 +263,16 @@ class TonConnectService { async handleDisconnectRequest( request: AppRequest<'disconnect'>, connectedApp: IConnectedApp, - connection: IConnectedAppConnection, + connection: WithWalletIdentifier, ): Promise> { if (connection.type === TonConnectBridgeType.Injected) { removeInjectedConnection(connectedApp.url); } else { - removeRemoteConnection(connectedApp, connection); + removeRemoteConnection( + tk.wallets.get(connection.walletIdentifier)!.isTestnet, + connectedApp, + connection, + ); } return { @@ -274,7 +283,7 @@ class TonConnectService { async sendTransaction( request: AppRequest<'sendTransaction'>, - connection: IConnectedAppConnection, + connection: WithWalletIdentifier, ): Promise> { try { const params = JSON.parse(request.params[0]) as SignRawParams; @@ -339,7 +348,9 @@ class TonConnectService { ), ), true, - connection.type === TonConnectBridgeType.Remote, + connection.type === TonConnectBridgeType.Remote && + connection.walletIdentifier === tk.wallet.identifier, + connection.walletIdentifier, ); if (!openModalResult) { @@ -375,7 +386,7 @@ class TonConnectService { private async handleRequest( request: AppRequest, connectedApp: IConnectedApp | null, - connection: IConnectedAppConnection | null, + connection: WithWalletIdentifier | null, ): Promise> { if (!connectedApp || !connection) { return { @@ -387,14 +398,14 @@ class TonConnectService { }; } - if (request.method === 'sendTransaction') { - return this.sendTransaction(request, connection); - } - if (request.method === 'disconnect') { return this.handleDisconnectRequest(request, connectedApp, connection); } + if (request.method === 'sendTransaction') { + return this.sendTransaction(request, connection); + } + return { error: { code: SEND_TRANSACTION_ERROR_CODES.BAD_REQUEST_ERROR, @@ -415,17 +426,28 @@ class TonConnectService { const connection = allConnections.find((item) => item.type === TonConnectBridgeType.Injected) || null; - return this.handleRequest(request, connectedApp, connection); + return this.handleRequest( + request, + connectedApp, + connection ? { ...connection, walletIdentifier: tk.wallet.identifier } : null, + ); } async handleRequestFromRemoteBridge( request: AppRequest, clientSessionId: string, + walletIdentifier: string, ): Promise> { - const { connectedApp, connection } = - findConnectedAppByClientSessionId(clientSessionId); + const { connectedApp, connection } = findConnectedAppByClientSessionId( + clientSessionId, + walletIdentifier, + ); - return this.handleRequest(request, connectedApp, connection); + return this.handleRequest( + request, + connectedApp, + connection ? { ...connection, walletIdentifier } : null, + ); } async disconnect(url: string) { diff --git a/packages/mobile/src/tonconnect/TonConnectRemoteBridge.ts b/packages/mobile/src/tonconnect/TonConnectRemoteBridge.ts index 59d49edea..3072d5001 100644 --- a/packages/mobile/src/tonconnect/TonConnectRemoteBridge.ts +++ b/packages/mobile/src/tonconnect/TonConnectRemoteBridge.ts @@ -28,6 +28,7 @@ import { TCEventID } from './EventID'; import { AppStackRouteNames } from '$navigation'; import { getCurrentRoute, goBack } from '$navigation/imperative'; import { delay } from '$utils'; +import { WithWalletIdentifier } from '$wallet/WalletTypes'; class TonConnectRemoteBridgeService { private readonly storeKey = 'ton-connect-http-bridge-lastEventId'; @@ -38,7 +39,7 @@ class TonConnectRemoteBridgeService { private eventSource: EventSource | null = null; - private connections: IConnectedAppConnectionRemote[] = []; + private connections: WithWalletIdentifier[] = []; private activeRequests: { [from: string]: AppRequest } = {}; @@ -56,12 +57,12 @@ class TonConnectRemoteBridgeService { } } - async open(connections: IConnectedAppConnection[]) { + async open(connections: WithWalletIdentifier[]) { this.close(); this.connections = connections.filter( (item) => item.type === TonConnectBridgeType.Remote, - ) as IConnectedAppConnectionRemote[]; + ) as WithWalletIdentifier[]; if (this.connections.length === 0) { return; @@ -193,7 +194,11 @@ class TonConnectRemoteBridgeService { console.log('handleMessage request', request); - const response = await TonConnect.handleRequestFromRemoteBridge(request, from); + const response = await TonConnect.handleRequestFromRemoteBridge( + request, + from, + connection.walletIdentifier, + ); delete this.activeRequests[from]; diff --git a/packages/mobile/src/tonconnect/config.ts b/packages/mobile/src/tonconnect/config.ts index 96d8b5c05..b37116efc 100644 --- a/packages/mobile/src/tonconnect/config.ts +++ b/packages/mobile/src/tonconnect/config.ts @@ -17,7 +17,7 @@ const getPlatform = (): DeviceInfo['platform'] => { export const tonConnectDeviceInfo: DeviceInfo = { platform: getPlatform(), appName: RNDeviceInfo.getApplicationName(), - appVersion: RNDeviceInfo.getReadableVersion(), + appVersion: RNDeviceInfo.getVersion(), maxProtocolVersion: CURRENT_PROTOCOL_VERSION, features: ['SendTransaction', { name: 'SendTransaction', maxMessages: 4 }], }; diff --git a/packages/mobile/src/tonconnect/hooks/useRemoteBridge.ts b/packages/mobile/src/tonconnect/hooks/useRemoteBridge.ts index 2c70f89ec..080dcaece 100644 --- a/packages/mobile/src/tonconnect/hooks/useRemoteBridge.ts +++ b/packages/mobile/src/tonconnect/hooks/useRemoteBridge.ts @@ -1,29 +1,28 @@ import { useAppState } from '$hooks/useAppState'; import { getAllConnections, useConnectedAppsStore } from '$store'; -import { walletSelector } from '$store/wallet'; import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; import { TonConnectRemoteBridge } from '../TonConnectRemoteBridge'; +import { useWallets } from '@tonkeeper/shared/hooks'; export const useRemoteBridge = () => { - const { address } = useSelector(walletSelector); + const wallets = useWallets(); + const walletIdentifiers = wallets.map((wallet) => wallet.identifier).join(','); const appState = useAppState(); + const shouldClose = appState === 'background'; + useEffect(() => { - if (appState !== 'active') { + if (shouldClose) { return; } - const initialConnections = getAllConnections( - useConnectedAppsStore.getState(), - address.ton, - ); + const initialConnections = getAllConnections(useConnectedAppsStore.getState()); TonConnectRemoteBridge.open(initialConnections); const unsubscribe = useConnectedAppsStore.subscribe( - (s) => getAllConnections(s, address.ton), + (s) => getAllConnections(s), (connections) => { TonConnectRemoteBridge.open(connections); }, @@ -33,5 +32,5 @@ export const useRemoteBridge = () => { unsubscribe(); TonConnectRemoteBridge.close(); }; - }, [address.ton, appState]); + }, [walletIdentifiers, shouldClose]); }; diff --git a/packages/mobile/src/tonconnect/index.ts b/packages/mobile/src/tonconnect/index.ts index ef5ad17da..c748a59d4 100644 --- a/packages/mobile/src/tonconnect/index.ts +++ b/packages/mobile/src/tonconnect/index.ts @@ -1,6 +1,5 @@ export * from './config'; export * from './TonConnect'; export * from './ConnectReplyBuilder'; -export * from './TonConnectRemoteBridge'; export * from './hooks'; export * from './models'; diff --git a/packages/mobile/src/uikit/Checkbox/Checkbox.tsx b/packages/mobile/src/uikit/Checkbox/Checkbox.tsx index d3f0cc2a1..172e6ef73 100644 --- a/packages/mobile/src/uikit/Checkbox/Checkbox.tsx +++ b/packages/mobile/src/uikit/Checkbox/Checkbox.tsx @@ -102,5 +102,6 @@ const styles = Steezy.create(({ colors }) => ({ borderWidth: 2, alignItems: 'center', justifyContent: 'center', + margin: 3, }, })); diff --git a/packages/mobile/src/uikit/InlineKeyboard/InlineKeyboard.tsx b/packages/mobile/src/uikit/InlineKeyboard/InlineKeyboard.tsx index 661410128..078b33c7a 100644 --- a/packages/mobile/src/uikit/InlineKeyboard/InlineKeyboard.tsx +++ b/packages/mobile/src/uikit/InlineKeyboard/InlineKeyboard.tsx @@ -6,14 +6,13 @@ import { useSharedValue, withTiming, } from 'react-native-reanimated'; -import * as LocalAuthentication from 'expo-local-authentication'; import { InlineKeyboardProps, KeyProps } from './InlineKeyboard.interface'; import * as S from './InlineKeyboard.style'; -import { detectBiometryType, triggerSelection } from '$utils'; +import { triggerSelection } from '$utils'; import { Icon } from '../Icon/Icon'; -import { useTheme } from '$hooks/useTheme'; -import { MainDB } from '$database'; +import { tk } from '$wallet'; +import { BiometryType } from '$wallet/Biometry'; const Key: FC = (props) => { const { onPress, children, disabled } = props; @@ -67,26 +66,6 @@ export const InlineKeyboard: FC = (props) => { biometryEnabled = false, onBiometryPress, } = props; - const theme = useTheme(); - const [biometryType, setBiometryType] = useState(-1); - - useEffect(() => { - if (biometryEnabled) { - Promise.all([ - MainDB.isBiometryEnabled(), - LocalAuthentication.supportedAuthenticationTypesAsync(), - ]).then(([isEnabled, types]) => { - if (isEnabled) { - const type = detectBiometryType(types); - if (type) { - setBiometryType(type); - } - } - }); - } else { - setBiometryType(-1); - } - }, [biometryEnabled]); const handlePress = useCallback( (num: number) => () => { @@ -119,22 +98,16 @@ export const InlineKeyboard: FC = (props) => { } let biometryButton = ; - if (biometryType === LocalAuthentication.AuthenticationType.FINGERPRINT) { - biometryButton = ( - - - - ); - } else if ( - biometryType === LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION - ) { + + if (biometryEnabled) { biometryButton = ( @@ -148,16 +121,13 @@ export const InlineKeyboard: FC = (props) => { 0 - + , ); return result; - }, [biometryType, disabled, handleBackspace, onBiometryPress, handlePress, theme]); + }, [biometryEnabled, handlePress, disabled, handleBackspace, onBiometryPress]); return {nums}; }; diff --git a/packages/mobile/src/uikit/List/ListItemWithCheckbox.tsx b/packages/mobile/src/uikit/List/ListItemWithCheckbox.tsx index 796d7abe2..8ba833584 100644 --- a/packages/mobile/src/uikit/List/ListItemWithCheckbox.tsx +++ b/packages/mobile/src/uikit/List/ListItemWithCheckbox.tsx @@ -19,13 +19,11 @@ export const ListItemWithCheckbox = memo((props) => { {...props} onPress={onPress || onChange} value={ - - - + } /> ); diff --git a/packages/mobile/src/uikit/NavBar/NavBar.interface.ts b/packages/mobile/src/uikit/NavBar/NavBar.interface.ts index 67e007248..b298ac435 100644 --- a/packages/mobile/src/uikit/NavBar/NavBar.interface.ts +++ b/packages/mobile/src/uikit/NavBar/NavBar.interface.ts @@ -4,7 +4,7 @@ import { ViewStyle } from 'react-native'; import { TextProps } from '../Text/Text'; export interface NavBarProps { - children: ReactNode; + children?: ReactNode; subtitle?: ReactNode; isModal?: boolean; title?: string | React.ReactNode; @@ -16,6 +16,7 @@ export interface NavBarProps { isClosedButton?: boolean; isBottomButton?: boolean; onBackPress?: () => void; + onClosePress?: () => void; onGoBack?: () => void; isTransparent?: boolean; isForceBackIcon?: boolean; diff --git a/packages/mobile/src/uikit/NavBar/NavBar.tsx b/packages/mobile/src/uikit/NavBar/NavBar.tsx index 0427c97d2..e2b62617b 100644 --- a/packages/mobile/src/uikit/NavBar/NavBar.tsx +++ b/packages/mobile/src/uikit/NavBar/NavBar.tsx @@ -39,6 +39,7 @@ export const NavBar: FC = (props) => { forceBigTitle = false, isClosedButton = false, onBackPress = undefined, + onClosePress = undefined, onGoBack = undefined, isTransparent = false, isForceBackIcon = false, @@ -98,7 +99,7 @@ export const NavBar: FC = (props) => { if (isClosedButton) { return ( - + diff --git a/packages/mobile/src/uikit/SwitchItem.tsx b/packages/mobile/src/uikit/SwitchItem.tsx index 2c46bff75..9c3f4d49f 100644 --- a/packages/mobile/src/uikit/SwitchItem.tsx +++ b/packages/mobile/src/uikit/SwitchItem.tsx @@ -15,7 +15,7 @@ interface SwitchItemProps { export const SwitchItem: React.FC = (props) => { const { icon, title, onChange, value, disabled, subtitle } = props; - const handleToggle = React.useCallback(() => onChange(!value), [value]); + const handleToggle = React.useCallback(() => onChange(!value), [onChange, value]); return ( diff --git a/packages/mobile/src/uikit/Tag/Tag.tsx b/packages/mobile/src/uikit/Tag/Tag.tsx index ea82918e6..b151dde3d 100644 --- a/packages/mobile/src/uikit/Tag/Tag.tsx +++ b/packages/mobile/src/uikit/Tag/Tag.tsx @@ -1,25 +1,28 @@ import { Steezy } from '$styles'; import React, { FC, memo } from 'react'; import { View } from '../StyledNativeComponents'; -import { Text } from '../Text/Text'; import { StyleSheet } from 'react-native'; import { TonThemeColor } from '$styled'; +import { Text } from '@tonkeeper/uikit'; +import { TextColors } from '@tonkeeper/uikit/src/components/Text/Text'; -export type TagType = 'default' | 'accent' | 'warning' | 'positive'; +export type TagType = 'default' | 'accent' | 'warning' | 'warningLight' | 'positive'; interface Props { type?: TagType; children: string; } -const getTextColor = (type: TagType): TonThemeColor => { +const getTextColor = (type: TagType): TextColors => { switch (type) { case 'accent': - return 'accentPrimary'; + return 'accentBlue'; case 'warning': return 'accentOrange'; + case 'warningLight': + return 'constantBlack'; case 'positive': - return 'accentPositive'; + return 'accentGreen'; case 'default': default: return 'textSecondary'; @@ -34,7 +37,7 @@ const TagComponent: FC = (props) => { return ( - + {children} @@ -67,7 +70,14 @@ const styles = Steezy.create(({ colors }) => ({ warning: { backgroundColor: colors.accentOrange, }, + warningLight: { + backgroundColor: colors.accentOrange, + opacity: 1, + }, positive: { backgroundColor: colors.accentGreen, }, + text: { + textTransform: 'uppercase', + }, })); diff --git a/packages/mobile/src/uikit/Text/Text.variants.ts b/packages/mobile/src/uikit/Text/Text.variants.ts index 163f54bcc..ba3ef3e95 100644 --- a/packages/mobile/src/uikit/Text/Text.variants.ts +++ b/packages/mobile/src/uikit/Text/Text.variants.ts @@ -63,11 +63,5 @@ export const textVariants = StyleSheet.create({ lineHeight: nfs(16), fontFamily: FONT.regular, }, - body4Caps: { - fontSize: nfs(10), - lineHeight: nfs(14), - fontFamily: FONT.medium, - textTransform: 'uppercase', - }, }); export type TTextVariants = keyof typeof textVariants; diff --git a/packages/mobile/src/uikit/Toast/new/ToastComponent.tsx b/packages/mobile/src/uikit/Toast/new/ToastComponent.tsx index 4243152ae..b63f60fca 100644 --- a/packages/mobile/src/uikit/Toast/new/ToastComponent.tsx +++ b/packages/mobile/src/uikit/Toast/new/ToastComponent.tsx @@ -101,7 +101,11 @@ export const ToastComponent = memo(() => { styles.toast, animatedStyle, toast.size === 'small' && styles.toastSmall, - { backgroundColor: theme.colors.backgroundTertiary }, + { + backgroundColor: toast.warning + ? theme.colors.accentOrange + : theme.colors.backgroundTertiary, + }, ]} > {toast.isLoading && ( diff --git a/packages/mobile/src/uikit/TonDiamondIcon/TonDiamondIcon.tsx b/packages/mobile/src/uikit/TonDiamondIcon/TonDiamondIcon.tsx index 174a9fbc5..245b7190b 100644 --- a/packages/mobile/src/uikit/TonDiamondIcon/TonDiamondIcon.tsx +++ b/packages/mobile/src/uikit/TonDiamondIcon/TonDiamondIcon.tsx @@ -5,7 +5,6 @@ import { getDiamondSizeRatio, } from '$styled'; import React, { FC, memo } from 'react'; -import { ViewStyle } from 'react-native'; import { IconFromUri } from './IconFromUri'; import * as S from './TonDiamondIcon.style'; @@ -14,7 +13,7 @@ interface Props { nftIcon?: AccentNFTIcon; size: number; disabled?: boolean; - iconAnimatedStyle?: any;//AnimatedStyleProp; + iconAnimatedStyle?: any; //AnimatedStyleProp; rounded?: boolean; } diff --git a/packages/mobile/src/utils/biometry.ts b/packages/mobile/src/utils/biometry.ts index 2c19e049b..7d7312bd0 100644 --- a/packages/mobile/src/utils/biometry.ts +++ b/packages/mobile/src/utils/biometry.ts @@ -1,24 +1,58 @@ -import * as LocalAuthentication from 'expo-local-authentication'; +import { BiometryType } from '$wallet/Biometry'; +import { t } from '@tonkeeper/shared/i18n'; +import { isIOS, platform } from './device'; +import { capitalizeFirstLetter } from './string'; +import { IconNames } from '@tonkeeper/uikit'; -export function detectBiometryType(types: LocalAuthentication.AuthenticationType[]) { - let found = false; - for (let type of types) { - if ( - [ - LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION, - LocalAuthentication.AuthenticationType.FINGERPRINT, - ].indexOf(type) > -1 - ) { - found = true; - break; +export const getBiometryName = ( + type: BiometryType, + params?: { + capitalized?: boolean; + genitive?: boolean; + accusative?: boolean; + instrumental?: boolean; + }, +) => { + if (type === BiometryType.Fingerprint) { + let text = t(`biometry.${platform}.fingerprint`); + if (params?.genitive) { + text = t(`biometry.${platform}.fingerprint_genitive`); } + if (params?.instrumental) { + text = t(`biometry.${platform}.fingerprint_instrumental`); + } + return params?.capitalized ? capitalizeFirstLetter(text) : text; + } + if (type === BiometryType.FaceRecognition) { + let text = t(`biometry.${platform}.face_recognition`); + if (params?.genitive) { + text = t(`biometry.${platform}.face_recognition_genitive`); + } + if (params?.instrumental) { + text = t(`biometry.${platform}.face_recognition_instrumental`); + } + return params?.capitalized ? capitalizeFirstLetter(text) : text; } - if (found) { - return types.indexOf(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION) > -1 - ? LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION - : LocalAuthentication.AuthenticationType.FINGERPRINT; - } else { - return null; + let text = t('biometry.default'); + if (params?.genitive) { + text = t('biometry.default_genitive'); + } + if (params?.accusative) { + text = t('biometry.default_accusative'); + } + if (params?.instrumental) { + text = t('biometry.default_instrumental'); + } + return params?.capitalized ? capitalizeFirstLetter(text) : text; +}; + +export const getBiometryIcon = (type: BiometryType): IconNames => { + if (type === BiometryType.Fingerprint) { + return isIOS ? 'ic-fingerprint-28' : 'ic-fingerprint-android-28'; + } + if (type === BiometryType.FaceRecognition) { + return isIOS ? 'ic-faceid-28' : 'ic-faceid-android-28'; } -} + return 'ic-fingerprint-android-28'; +}; diff --git a/packages/mobile/src/utils/currency.ts b/packages/mobile/src/utils/currency.ts index ad7021ca3..6123ba3c1 100644 --- a/packages/mobile/src/utils/currency.ts +++ b/packages/mobile/src/utils/currency.ts @@ -8,10 +8,11 @@ import { truncateDecimal, } from './number'; import { getNumberFormatSettings } from 'react-native-localize'; +import { WalletCurrency } from '$shared/constants'; export function formatFiatCurrencyAmount( amount: any, - currency: FiatCurrency, + currency: FiatCurrency | WalletCurrency, hasThinSpace?: boolean, hasNegative?: boolean, ): string { diff --git a/packages/mobile/src/utils/flags.ts b/packages/mobile/src/utils/flags.ts index 402a419f8..dcec3e605 100644 --- a/packages/mobile/src/utils/flags.ts +++ b/packages/mobile/src/utils/flags.ts @@ -1,8 +1,8 @@ import React from 'react'; -import { getServerConfig } from '$shared/constants'; +import { config } from '$config'; export function getFlag(key?: string) { - const flags = getServerConfig('flags'); + const flags = config.get('flags'); if (key) { const flag = flags[key]; if (__DEV__ && flags[key] === undefined) { diff --git a/packages/mobile/src/utils/mapNewNftToOldNftData.ts b/packages/mobile/src/utils/mapNewNftToOldNftData.ts new file mode 100644 index 000000000..f5d6969c1 --- /dev/null +++ b/packages/mobile/src/utils/mapNewNftToOldNftData.ts @@ -0,0 +1,46 @@ +import { CryptoCurrencies } from '$shared/constants'; +import { NFTModel } from '$store/models'; +import { NftItem } from '@tonkeeper/core/src/TonAPI'; +import TonWeb from 'tonweb'; +import { getFlag } from './flags'; +import { Address } from '@tonkeeper/core'; + +export const mapNewNftToOldNftData = ( + nftItem: NftItem, + walletFriendlyAddress, +): NFTModel => { + const address = new TonWeb.utils.Address(nftItem.address).toString(true, true, true); + const ownerAddress = nftItem.owner?.address + ? Address.parse(nftItem.owner.address, { + bounceable: !getFlag('address_style_nobounce'), + }).toFriendly() + : ''; + const name = + typeof nftItem.metadata?.name === 'string' + ? nftItem.metadata.name.trim() + : nftItem.metadata?.name; + + const baseUrl = (nftItem.previews && + nftItem.previews.find((preview) => preview.resolution === '500x500')!.url)!; + + return { + ...nftItem, + ownerAddressToDisplay: nftItem.sale ? walletFriendlyAddress : undefined, + isApproved: !!nftItem.approved_by?.length ?? false, + internalId: `${CryptoCurrencies.Ton}_${address}`, + currency: CryptoCurrencies.Ton, + provider: 'TonProvider', + content: { + image: { + baseUrl, + }, + }, + description: nftItem.metadata?.description, + marketplaceURL: nftItem.metadata?.marketplace && nftItem.metadata?.external_url, + attributes: nftItem.metadata?.attributes, + address, + name, + ownerAddress, + collection: nftItem.collection, + }; +}; diff --git a/packages/mobile/src/utils/messaging.ts b/packages/mobile/src/utils/messaging.ts deleted file mode 100644 index b9c089d17..000000000 --- a/packages/mobile/src/utils/messaging.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { isAndroid } from '$utils'; -import { debugLog } from '$utils/debugLog'; -import { PermissionsAndroid, Platform } from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import messaging from '@react-native-firebase/messaging'; -import { getTimeSec } from './getTimeSec'; -import _ from "lodash"; - -export async function getToken() { - return await messaging().getToken(); -} - -export async function getPermission() { - if (isAndroid && +Platform.Version >= 33) { - return await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); - } else { - const authStatus = await messaging().hasPermission(); - const enabled = - authStatus === messaging.AuthorizationStatus.AUTHORIZED || - authStatus === messaging.AuthorizationStatus.PROVISIONAL; - - return enabled; - } -} - -export async function requestUserPermissionAndGetToken() { - if (isAndroid && +Platform.Version >= 33) { - const status = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); - const enabled = status === PermissionsAndroid.RESULTS.GRANTED; - - if (!enabled) { - return false; - } - } else { - const hasPermission = await getPermission(); - - if (!hasPermission) { - const authStatus = await messaging().requestPermission(); - const enabled = - authStatus === messaging.AuthorizationStatus.AUTHORIZED || - authStatus === messaging.AuthorizationStatus.PROVISIONAL; - - if (!enabled) { - return false; - } - } - } - - return await getToken(); -} - -export enum SUBSCRIBE_STATUS { - SUBSCRIBED, - UNSUBSCRIBED, - NOT_SPECIFIED, -} - -let _subscribeStatus: SUBSCRIBE_STATUS = SUBSCRIBE_STATUS.NOT_SPECIFIED; - -export async function saveSubscribeStatus() { - try { - await AsyncStorage.setItem('isSubscribeNotifications', 'true'); - _subscribeStatus = SUBSCRIBE_STATUS.SUBSCRIBED; - } catch (err) { - _subscribeStatus = SUBSCRIBE_STATUS.NOT_SPECIFIED; - debugLog('[saveSubscribeStatus]', err); - } -} - -export async function removeSubscribeStatus() { - try { - await AsyncStorage.setItem('isSubscribeNotifications', 'false'); - _subscribeStatus = SUBSCRIBE_STATUS.UNSUBSCRIBED; - } catch (err) { - _subscribeStatus = SUBSCRIBE_STATUS.NOT_SPECIFIED; - debugLog('[removeSubscribeStatus]', err); - } -} - -export async function clearSubscribeStatus() { - try { - await AsyncStorage.removeItem('isSubscribeNotifications'); - _subscribeStatus = SUBSCRIBE_STATUS.NOT_SPECIFIED; - } catch (err) { - _subscribeStatus = SUBSCRIBE_STATUS.NOT_SPECIFIED; - debugLog('[removeSubscribeStatus]', err); - } -} - -export async function getSubscribeStatus() { - if (_subscribeStatus !== SUBSCRIBE_STATUS.NOT_SPECIFIED) { - return _subscribeStatus; - } - - try { - const status = await AsyncStorage.getItem('isSubscribeNotifications'); - if (_.isNil(status)) { - _subscribeStatus = SUBSCRIBE_STATUS.NOT_SPECIFIED; - } else if (status === 'true') { - _subscribeStatus = SUBSCRIBE_STATUS.SUBSCRIBED; - } else { - _subscribeStatus = SUBSCRIBE_STATUS.UNSUBSCRIBED; - } - return _subscribeStatus; - } catch (err) { - _subscribeStatus = SUBSCRIBE_STATUS.NOT_SPECIFIED; - return false; - } -} - -export async function saveReminderNotifications() { - try { - const twoWeeksInSec = 60 * 60 * 24 * 7 * 2; - const nextShowTime = getTimeSec() + twoWeeksInSec; - await AsyncStorage.setItem('ReminderNotificationsTimestamp', String(nextShowTime)); - } catch (err) {} -} - -export async function removeReminderNotifications() { - try { - await AsyncStorage.removeItem('ReminderNotificationsTimestamp'); - } catch (err) {} -} - -export async function shouldOpenReminderNotifications() { - try { - const status = await getSubscribeStatus(); - const timeToShow = await AsyncStorage.getItem('ReminderNotificationsTimestamp'); - - if (status === SUBSCRIBE_STATUS.NOT_SPECIFIED) { - if (!timeToShow) { - return true; - } - - return getTimeSec() > Number(timeToShow); - } - - return false; - } catch (err) { - console.error(err); - return false; - } -} - -export async function saveDontShowReminderNotifications() { - try { - await AsyncStorage.setItem('DontShowReminderNotifications', 'true'); - } catch (err) {} -} - -export async function shouldOpenReminderNotificationsAfterUpdate() { - try { - const hasPermission = await getPermission(); - const dontShow = await AsyncStorage.getItem('DontShowReminderNotifications'); - - return !hasPermission && !dontShow; - } catch (err) { - debugLog(err); - return false; - } -} diff --git a/packages/mobile/src/utils/nft.ts b/packages/mobile/src/utils/nft.ts index ce6700e0f..4e07c4bef 100644 --- a/packages/mobile/src/utils/nft.ts +++ b/packages/mobile/src/utils/nft.ts @@ -1,12 +1,7 @@ import { tonDiamondCollectionAddress, telegramNumbersAddress } from '$shared/constants'; import { getChainName } from '$shared/dynamicConfig'; -import { MarketplaceModel, NFTModel, TonDiamondMetadata } from '$store/models'; -import { myNftsSelector } from '$store/nfts'; -import { AccentKey } from '$styled'; -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { NFTModel, TonDiamondMetadata } from '$store/models'; import TonWeb from 'tonweb'; -import { capitalizeFirstLetter } from './string'; const getTonDiamondsCollectionAddress = () => tonDiamondCollectionAddress[getChainName()]; const getTelegramNumbersCollectionAddress = () => telegramNumbersAddress[getChainName()]; @@ -39,34 +34,3 @@ export const checkIsTelegramNumbersNFT = (nft: NFTModel): boolean => { return collectionAddress === getTelegramNumbersCollectionAddress(); }; - -export const useHasDiamondsOnBalance = () => { - const myNfts = useSelector(myNftsSelector); - const diamond = useMemo(() => { - return Object.values(myNfts).find(checkIsTonDiamondsNFT); - }, [myNfts]); - - return !!diamond; -}; - -export const getDiamondsCollectionMarketUrl = ( - marketplace: MarketplaceModel, - accentKey: AccentKey, -) => { - const color = capitalizeFirstLetter(accentKey); - - const collectionAddress = getTonDiamondsCollectionAddress(); - - switch (marketplace.id) { - case 'getgems': - return `${ - marketplace.marketplace_url - }/collection/${collectionAddress}/?filter=${encodeURIComponent( - JSON.stringify({ attributes: { Color: [color] } }), - )}`; - case 'tonDiamonds': - return `${marketplace.marketplace_url}/collection/${collectionAddress}?traits[Color][0]=${color}`; - default: - return marketplace.marketplace_url; - } -}; diff --git a/packages/mobile/src/utils/proof.ts b/packages/mobile/src/utils/proof.ts index bc9d082a9..80e66e51f 100644 --- a/packages/mobile/src/utils/proof.ts +++ b/packages/mobile/src/utils/proof.ts @@ -5,8 +5,8 @@ import nacl from 'tweetnacl'; import naclUtils from 'tweetnacl-util'; const { createHash } = require('react-native-crypto'); import { ConnectApi, Configuration } from '@tonkeeper/core/src/legacy'; -import { getServerConfigSafe } from '$shared/constants'; import { Address } from '@tonkeeper/core'; +import { config } from '$config'; export interface TonProofArgs { address: string; @@ -27,9 +27,9 @@ export async function createTonProof({ const address = Address.parse(_addr).toRaw(); const connectApi = new ConnectApi( new Configuration({ - basePath: getServerConfigSafe('tonapiV2Endpoint'), + basePath: config.get('tonapiV2Endpoint'), headers: { - Authorization: `Bearer ${getServerConfigSafe('tonApiV2Key')}`, + Authorization: `Bearer ${config.get('tonApiV2Key')}`, }, }), ); diff --git a/packages/mobile/src/utils/proxyMedia.ts b/packages/mobile/src/utils/proxyMedia.ts index 71cfb6817..0c394091a 100644 --- a/packages/mobile/src/utils/proxyMedia.ts +++ b/packages/mobile/src/utils/proxyMedia.ts @@ -1,10 +1,9 @@ +import { config } from '$config'; import { ns } from '$utils/style'; -import { getServerConfig } from '$shared/constants'; const createHmac = require('create-hmac'); const urlSafeBase64 = (string) => { - // eslint-disable-next-line no-undef return Buffer.from(string) .toString('base64') .replace(/[=]/g, '') @@ -12,7 +11,6 @@ const urlSafeBase64 = (string) => { .replace(/\//g, '_'); }; -// eslint-disable-next-line no-undef const hexDecode = (hex) => Buffer.from(hex, 'hex'); const sign = (salt, target, secret) => { @@ -32,13 +30,13 @@ const EXTENTION = 'png'; Please provide width and height without additional normalizing */ export function proxyMedia(url: string, width: number = 300, height: number = 300) { - const KEY = getServerConfig('cachedMediaKey'); - const SALT = getServerConfig('cachedMediaSalt'); + const KEY = config.get('cachedMediaKey'); + const SALT = config.get('cachedMediaSalt'); const encoded_url = urlSafeBase64(url); const path = `/rs:${RESIZING_TYPE}:${Math.round(ns(width))}:${Math.round( ns(height), )}:${enlarge}/g:${GRAVITY}/${encoded_url}.${EXTENTION}`; const signature = sign(SALT, path, KEY); - return `${getServerConfig('cachedMediaEndpoint')}/${signature}${path}`; + return `${config.get('cachedMediaEndpoint')}/${signature}${path}`; } diff --git a/packages/mobile/src/utils/staking.ts b/packages/mobile/src/utils/staking.ts index d343b3ce4..b6ab6fc0f 100644 --- a/packages/mobile/src/utils/staking.ts +++ b/packages/mobile/src/utils/staking.ts @@ -9,10 +9,7 @@ import { whalesTeam2IconSource, whalesTeamIconSource, } from '@tonkeeper/uikit/assets/staking'; -import { Ton } from '$libs/Ton'; -import { StakingInfo } from '$store'; import { PoolInfo, PoolImplementationType } from '@tonkeeper/core/src/TonAPI'; -import BigNumber from 'bignumber.js'; import { ImageRequireSource } from 'react-native'; export const getPoolIcon = (pool: PoolInfo): ImageRequireSource | null => { @@ -50,22 +47,3 @@ export const getImplementationIcon = (implementation: string) => { return liquidTfIconSource; } }; - -export const calculatePoolBalance = (pool: PoolInfo, stakingInfo: StakingInfo) => { - const amount = new BigNumber(Ton.fromNano(stakingInfo[pool.address]?.amount || '0')); - const pendingDeposit = new BigNumber( - Ton.fromNano(stakingInfo[pool.address]?.pending_deposit || '0'), - ); - const pendingWithdraw = new BigNumber( - Ton.fromNano(stakingInfo[pool.address]?.pending_withdraw || '0'), - ); - const readyWithdraw = new BigNumber( - Ton.fromNano(stakingInfo[pool.address]?.ready_withdraw || '0'), - ); - const balance = amount - .plus(pendingDeposit) - .plus(readyWithdraw) - .plus(pool.implementation === PoolImplementationType.LiquidTF ? pendingWithdraw : 0); - - return balance; -}; diff --git a/packages/mobile/src/utils/stats.ts b/packages/mobile/src/utils/stats.ts index 95518911c..ae3f9b379 100644 --- a/packages/mobile/src/utils/stats.ts +++ b/packages/mobile/src/utils/stats.ts @@ -1,10 +1,25 @@ -import { getServerConfig } from '$shared/constants'; +import { config } from '$config'; import { init, logEvent } from '@amplitude/analytics-browser'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import Aptabase from '@aptabase/react-native'; +import DeviceInfo from 'react-native-device-info'; +import { firebase } from '@react-native-firebase/analytics'; + +firebase + .analytics() + .setUserId(DeviceInfo.getUniqueId()) + .catch(() => null); let TrakingEnabled = false; export function initStats() { - init(getServerConfig('amplitudeKey'), '-', { + const appKey = config.get('aptabaseAppKey'); + if (appKey) { + Aptabase.init(appKey, { + host: config.get('aptabaseEndpoint'), + appVersion: DeviceInfo.getVersion(), + }); + } + init(config.get('amplitudeKey'), '-', { minIdLength: 1, deviceId: '-', trackingOptions: { @@ -16,23 +31,31 @@ export function initStats() { platform: true, adid: false, carrier: false, - } + }, }); - TrakingEnabled = true; + TrakingEnabled = true; } -export function trackEvent(name: string, params: any = {}) { - if (!TrakingEnabled) { - return; - } - logEvent(name, params); +export async function trackEvent(name: string, params: any = {}) { + try { + const appKey = config.get('aptabaseAppKey'); + if (!TrakingEnabled) { + return; + } + if (appKey) { + Aptabase.trackEvent( + name, + Object.assign(params, { firebase_user_id: DeviceInfo.getUniqueId() }), + ); + } + logEvent(name, params); + } catch (e) {} } - export async function trackFirstLaunch() { const isFirstLaunch = !(await AsyncStorage.getItem('launched_before')); if (isFirstLaunch) { trackEvent('first_launch'); await AsyncStorage.setItem('launched_before', 'true'); } -} \ No newline at end of file +} diff --git a/packages/@core-js/src/Activity/ActivityList.ts b/packages/mobile/src/wallet/Activity/ActivityList.ts similarity index 91% rename from packages/@core-js/src/Activity/ActivityList.ts rename to packages/mobile/src/wallet/Activity/ActivityList.ts index de26e5df5..4150f6286 100644 --- a/packages/@core-js/src/Activity/ActivityList.ts +++ b/packages/mobile/src/wallet/Activity/ActivityList.ts @@ -1,15 +1,13 @@ import { ActivitySection, ActionItem, ActivityModel } from '../models/ActivityModel'; -import { Storage } from '../declarations/Storage'; import { ActivityLoader } from './ActivityLoader'; -import { Logger } from '../utils/Logger'; -import { State } from '../utils/State'; +import { State, Storage, Logger } from '@tonkeeper/core'; type Cursors = { tron: number | null; ton: number | null; }; -type ActivityListState = { +export type ActivityListState = { sections: ActivitySection[]; error?: string | null; isReloading: boolean; @@ -32,10 +30,14 @@ export class ActivityList { error: null, }); - constructor(private activityLoader: ActivityLoader, private storage: Storage) { + constructor( + private persistPath: string, + private activityLoader: ActivityLoader, + private storage: Storage, + ) { this.state.persist({ storage: this.storage, - key: 'ActivityList', + key: `${this.persistPath}/ActivityList`, partialize: ({ sections }) => ({ sections: sections.map((section) => ({ ...section, @@ -140,8 +142,4 @@ export class ActivityList { private sortByTimestamp(items: ActionItem[]) { return items.sort((a, b) => b.event.timestamp - a.event.timestamp); } - - public preload() { - this.load(); - } } diff --git a/packages/@core-js/src/Activity/ActivityLoader.ts b/packages/mobile/src/wallet/Activity/ActivityLoader.ts similarity index 90% rename from packages/@core-js/src/Activity/ActivityLoader.ts rename to packages/mobile/src/wallet/Activity/ActivityLoader.ts index 29c6f7947..1278eeae7 100644 --- a/packages/@core-js/src/Activity/ActivityLoader.ts +++ b/packages/mobile/src/wallet/Activity/ActivityLoader.ts @@ -1,12 +1,12 @@ -import { AccountEvent, TonAPI } from '../TonAPI'; -import { WalletAddresses } from '../Wallet'; -import { TronAPI } from '../TronAPI'; +import { AccountEvent, TonAPI } from '@tonkeeper/core/src/TonAPI'; import { ActivityModel, ActionSource, ActionItem, ActionId, } from '../models/ActivityModel'; +import { TonRawAddress } from '../WalletTypes'; +import { TronAPI } from '@tonkeeper/core'; type LoadParams = { filter?: (events: TData[]) => TData[]; @@ -19,7 +19,7 @@ export class ActivityLoader { private tonActions = new Map(); constructor( - private addresses: WalletAddresses, + private tonRawAddress: TonRawAddress, private tonapi: TonAPI, private tronapi: TronAPI, ) {} @@ -28,7 +28,7 @@ export class ActivityLoader { const limit = params.limit ?? 50; const data = await this.tonapi.accounts.getAccountEvents({ before_lt: params.cursor ?? undefined, - accountId: this.addresses.ton, + accountId: this.tonRawAddress, subject_only: true, limit, }); @@ -36,7 +36,7 @@ export class ActivityLoader { const events = params.filter ? params.filter(data.events) : data.events; const actions = ActivityModel.createActions( { - ownerAddress: this.addresses.ton, + ownerAddress: this.tonRawAddress, source: ActionSource.Ton, events, }, @@ -80,14 +80,14 @@ export class ActivityLoader { const limit = params.limit ?? 50; const data = await this.tonapi.accounts.getAccountJettonHistoryById({ before_lt: params.cursor ?? undefined, - accountId: this.addresses.ton, + accountId: this.tonRawAddress, jettonId: params.jettonId, limit, }); const actions = ActivityModel.createActions( { - ownerAddress: this.addresses.ton, + ownerAddress: this.tonRawAddress, source: ActionSource.Ton, events: data.events, }, @@ -105,13 +105,13 @@ export class ActivityLoader { public async loadTonAction(actionId: ActionId) { const { eventId, actionIndex } = this.splitActionId(actionId); const event = await this.tonapi.accounts.getAccountEvent({ - accountId: this.addresses.ton, + accountId: this.tonRawAddress, eventId, }); if (event) { const action = ActivityModel.createAction({ - ownerAddress: this.addresses.ton, + ownerAddress: this.tonRawAddress, source: ActionSource.Ton, actionIndex, event, diff --git a/packages/@core-js/src/Activity/JettonActivityList.ts b/packages/mobile/src/wallet/Activity/JettonActivityList.ts similarity index 91% rename from packages/@core-js/src/Activity/JettonActivityList.ts rename to packages/mobile/src/wallet/Activity/JettonActivityList.ts index 0e8a06886..dc861ee2b 100644 --- a/packages/@core-js/src/Activity/JettonActivityList.ts +++ b/packages/mobile/src/wallet/Activity/JettonActivityList.ts @@ -1,8 +1,8 @@ import { ActivityModel, ActivitySection } from '../models/ActivityModel'; -import { Storage } from '../declarations/Storage'; + import { ActivityLoader } from './ActivityLoader'; -import { Logger } from '../utils/Logger'; -import { State } from '../utils/State'; + +import { Logger, State, Storage } from '@tonkeeper/core'; type JettonActivityListState = { sections: { [key in string]: ActivitySection[] }; @@ -24,14 +24,18 @@ export class JettonActivityList { error: null, }); - constructor(private activityLoader: ActivityLoader, private storage: Storage) { + constructor( + private persistPath: string, + private activityLoader: ActivityLoader, + private storage: Storage, + ) { this.state.persist({ partialize: ({ sections }) => ({ sections }), storage: this.storage, - key: 'JettonActivityList', + key: `${this.persistPath}/JettonActivityList`, }); } - + public async load(jettonId: string, cursor?: number | null) { try { this.state.set({ isLoading: true, error: null }); diff --git a/packages/@core-js/src/Activity/TonActivityList.ts b/packages/mobile/src/wallet/Activity/TonActivityList.ts similarity index 91% rename from packages/@core-js/src/Activity/TonActivityList.ts rename to packages/mobile/src/wallet/Activity/TonActivityList.ts index c1bc41377..3742441a0 100644 --- a/packages/@core-js/src/Activity/TonActivityList.ts +++ b/packages/mobile/src/wallet/Activity/TonActivityList.ts @@ -1,9 +1,8 @@ import { ActivityModel, ActivitySection } from '../models/ActivityModel'; -import { AccountEvent, ActionTypeEnum } from '../TonAPI'; -import { Storage } from '../declarations/Storage'; +import { Logger, State, Storage } from '@tonkeeper/core'; import { ActivityLoader } from './ActivityLoader'; -import { Logger } from '../utils/Logger'; -import { State } from '../utils/State'; + +import { AccountEvent, ActionTypeEnum } from '@tonkeeper/core/src/TonAPI'; type TonActivityListState = { sections: ActivitySection[]; @@ -25,11 +24,15 @@ export class TonActivityList { error: null, }); - constructor(private activityLoader: ActivityLoader, private storage: Storage) { + constructor( + private persistPath: string, + private activityLoader: ActivityLoader, + private storage: Storage, + ) { this.state.persist({ partialize: ({ sections }) => ({ sections }), storage: this.storage, - key: 'TonActivityList', + key: `${this.persistPath}/TonActivityList`, }); } diff --git a/packages/mobile/src/wallet/AppVault.ts b/packages/mobile/src/wallet/AppVault.ts new file mode 100644 index 000000000..75156f359 --- /dev/null +++ b/packages/mobile/src/wallet/AppVault.ts @@ -0,0 +1,272 @@ +import { Vault } from '@tonkeeper/core'; +import { Mnemonic } from '@tonkeeper/core/src/utils/mnemonic'; +import { Buffer } from 'buffer'; +import { generateSecureRandom } from 'react-native-securerandom'; +import scrypt from 'react-native-scrypt'; +import * as SecureStore from 'expo-secure-store'; + +const { nacl } = require('react-native-tweetnacl'); + +interface DecryptedData { + identifier: string; + mnemonic: string; +} + +type VaultState = Record; + +export class AppVault implements Vault { + constructor() {} + protected keychainService = 'TKProtected'; + protected walletsKey = 'wallets'; + protected biometryKey = 'biometry_passcode'; + + private decryptedVaultState: VaultState = {}; + + private async saveVaultState(passcode: string) { + const state = JSON.stringify(this.decryptedVaultState); + const encrypted = await ScryptBox.encrypt(passcode, state); + const encryptedString = JSON.stringify(encrypted); + + // Calculate the number of chunks required (2048 bytes per chunk) + const chunkSize = 2048; + let index = 0; + + // While loop to break down the string into chunks and save each one + while (index < encryptedString.length) { + const chunk = encryptedString.substring( + index, + Math.min(index + chunkSize, encryptedString.length), + ); + await SecureStore.setItemAsync( + `${this.walletsKey}_chunk_${index / chunkSize}`, + chunk, + ); + index += chunkSize; + } + + // Save an additional item that records the number of chunks + await SecureStore.setItemAsync( + `${this.walletsKey}_chunks`, + String(Math.ceil(encryptedString.length / chunkSize)), + ); + } + + public async verifyPasscode(passcode: string) { + await this.getDecryptedVaultState(passcode); + } + + public async import(identifier: string, mnemonic: string, passcode: string) { + /** check passcode */ + if (Object.keys(this.decryptedVaultState).length > 0) { + await this.verifyPasscode(passcode); + } + + if (!(await Mnemonic.validateMnemonic(mnemonic.split(' ')))) { + throw new Error('Mnemonic phrase is incorrect'); + } + + const keyPair = await Mnemonic.mnemonicToKeyPair(mnemonic.split(' ')); + + this.decryptedVaultState[identifier] = { identifier, mnemonic }; + + await this.saveVaultState(passcode); + + return keyPair; + } + + public async remove(identifier: string, passcode: string) { + delete this.decryptedVaultState[identifier]; + + await this.saveVaultState(passcode); + } + + public async destroy() { + try { + this.decryptedVaultState = {}; + const chunksCountStr = await SecureStore.getItemAsync(`${this.walletsKey}_chunks`); + if (chunksCountStr !== null) { + const chunksCount = parseInt(chunksCountStr, 10); + for (let i = 0; i < chunksCount; i++) { + await SecureStore.deleteItemAsync(`${this.walletsKey}_chunk_${i}`); + } + await SecureStore.deleteItemAsync(`${this.walletsKey}_chunks`); + } + await SecureStore.deleteItemAsync(this.biometryKey, { + keychainService: this.keychainService, + }); + } catch {} + } + + private async getDecryptedVaultState(passcode: string) { + // First, get the number of chunks to expect + const chunksCountStr = await SecureStore.getItemAsync(`${this.walletsKey}_chunks`); + + if (!chunksCountStr) { + throw new Error('Vault is empty or corrupt.'); + } + + const chunksCount = parseInt(chunksCountStr, 10); + let encryptedString = ''; + + // Loop through all chunks, concatenating them into a single string + for (let i = 0; i < chunksCount; i++) { + const chunk = await SecureStore.getItemAsync(`${this.walletsKey}_chunk_${i}`); + if (chunk) { + encryptedString += chunk; + } else { + throw new Error(`Missing chunk ${i}`); + } + } + + const encrypted = JSON.parse(encryptedString); + const stateJson = await ScryptBox.decrypt(passcode, encrypted); + + return JSON.parse(stateJson) as VaultState; + } + + public async exportWithPasscode(identifier: string, passcode: string) { + this.decryptedVaultState = await this.getDecryptedVaultState(passcode); + + const data = this.decryptedVaultState[identifier]; + + if (!data) { + throw new Error(`Mnemonic doesn't exist, identifier: ${identifier}`); + } + + return data.mnemonic; + } + + public async changePasscode(passcode: string, newPasscode: string) { + this.decryptedVaultState = await this.getDecryptedVaultState(passcode); + + await this.saveVaultState(newPasscode); + } + + public async exportPasscodeWithBiometry() { + const passcode = await SecureStore.getItemAsync(this.biometryKey, { + keychainService: this.keychainService, + }); + + if (!passcode) { + throw new Error('Biometry data is not found'); + } + + return passcode; + } + + public async setupBiometry(passcode: string) { + await SecureStore.setItemAsync(this.biometryKey, passcode, { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + keychainService: this.keychainService, + requireAuthentication: true, + }); + } + + public async removeBiometry() { + await SecureStore.deleteItemAsync(this.biometryKey, { + keychainService: this.keychainService, + }); + } +} + +export const ScryptBox = { + async encrypt(passcode: string, value: string) { + // default parameters + const N = 16384; // 16K*128*8 = 16 Mb of memory + const r = 8; + const p = 1; + + const salt = Buffer.from(await generateSecureRandom(32)); + const enckey = await scrypt( + Buffer.from(passcode, 'utf8'), + salt, + N, + r, + p, + 32, + 'buffer', + ); + const nonce = salt.slice(0, 24); + const ct: Uint8Array = nacl.secretbox( + Uint8Array.from(Buffer.from(value, 'utf8')), + Uint8Array.from(nonce), + Uint8Array.from(enckey), + ); + + return { + kind: 'encrypted-scrypt-tweetnacl', + N: N, // scrypt "cost" parameter + r: r, // scrypt "block size" parameter + p: p, // scrypt "parallelization" parameter + salt: salt.toString('hex'), // hex-encoded nonce/salt + ct: Buffer.from(ct).toString('hex'), // hex-encoded ciphertext + }; + }, + // Attempts to decrypt the vault and returns `true` if succeeded. + async decrypt(password: string, state: any): Promise { + if (state.kind === 'encrypted-scrypt-tweetnacl') { + const salt = Buffer.from(state.salt, 'hex'); + const { N, r, p } = state; + const enckey = await scrypt( + Buffer.from(password, 'utf8'), + salt, + N, + r, + p, + 32, + 'buffer', + ); + const nonce = salt.slice(0, 24); + const ct = Buffer.from(state.ct, 'hex'); + const pt = nacl.secretbox.open(ct, Uint8Array.from(nonce), Uint8Array.from(enckey)); + if (pt) { + const phrase = Utf8ArrayToString(pt); + return phrase; + } else { + throw new Error('Invald Passcode'); + } + } else { + throw new Error('Unsupported encryption format ' + state.kind); + } + }, +}; + +function Utf8ArrayToString(array: Uint8Array): string { + let out = ''; + let len = array.length; + let i = 0; + let c: any = null; + while (i < len) { + c = array[i++]; + switch (c >> 4) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + // 0xxxxxxx + out += String.fromCharCode(c); + break; + case 12: + case 13: + // 110x xxxx 10xx xxxx + let char = array[i++]; + out += String.fromCharCode(((c & 0x1f) << 6) | (char & 0x3f)); + break; + case 14: + // 1110 xxxx 10xx xxxx 10xx xxxx + let a = array[i++]; + let b = array[i++]; + out += String.fromCharCode( + ((c & 0x0f) << 12) | ((a & 0x3f) << 6) | ((b & 0x3f) << 0), + ); + break; + } + } + return out; +} + +export const vault = new AppVault(); diff --git a/packages/mobile/src/wallet/Biometry.ts b/packages/mobile/src/wallet/Biometry.ts new file mode 100644 index 000000000..525c72e7e --- /dev/null +++ b/packages/mobile/src/wallet/Biometry.ts @@ -0,0 +1,45 @@ +import * as LocalAuthentication from 'expo-local-authentication'; + +export enum BiometryType { + FaceRecognition = 'face_recognition', + Fingerprint = 'fingerprint', + BothTypes = 'both', + None = 'none', +} + +export class Biometry { + public type = BiometryType.None; + public isAvailable = false; + + public async detectTypes() { + try { + const [authTypes, enrolledLevel] = await Promise.all([ + LocalAuthentication.supportedAuthenticationTypesAsync(), + LocalAuthentication.getEnrolledLevelAsync(), + ]); + + this.isAvailable = + enrolledLevel === LocalAuthentication.SecurityLevel.BIOMETRIC_STRONG; + + const hasFingerprint = authTypes.includes( + LocalAuthentication.AuthenticationType.FINGERPRINT, + ); + + const hasFaceRecognition = authTypes.includes( + LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION, + ); + + if (hasFingerprint && hasFaceRecognition) { + this.type = BiometryType.BothTypes; + } else if (hasFingerprint) { + this.type = BiometryType.Fingerprint; + } else if (hasFaceRecognition) { + this.type = BiometryType.FaceRecognition; + } else { + this.type = BiometryType.None; + } + } catch {} + + return this; + } +} diff --git a/packages/mobile/src/wallet/Tonkeeper.ts b/packages/mobile/src/wallet/Tonkeeper.ts new file mode 100644 index 000000000..1fe5a8834 --- /dev/null +++ b/packages/mobile/src/wallet/Tonkeeper.ts @@ -0,0 +1,533 @@ +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { Wallet } from '$wallet/Wallet'; +import { Address } from '@tonkeeper/core/src/formatters/Address'; +import { TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { TonPriceManager } from './managers/TonPriceManager'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { + ImportWalletInfo, + WalletConfig, + WalletContractVersion, + WalletNetwork, + WalletStyleConfig, + WalletType, +} from './WalletTypes'; +import { createTonApiInstance } from './utils'; +import { Vault } from '@tonkeeper/core'; +import { v4 as uuidv4 } from 'uuid'; +import { Mnemonic } from '@tonkeeper/core/src/utils/mnemonic'; +import { DEFAULT_WALLET_STYLE_CONFIG, DEFAULT_WALLET_VERSION } from './constants'; +import { Buffer } from 'buffer'; +import nacl from 'tweetnacl'; +import { AccountsStream } from './streaming'; +import { InteractionManager } from 'react-native'; +import { Biometry } from './Biometry'; + +type TonkeeperOptions = { + storage: Storage; + vault: Vault; +}; + +export interface MultiWalletMigrationData { + pubkey: string; + keychainItemName: string; + biometryEnabled: boolean; + lockupConfig?: { + wallet_type: WalletContractVersion.LockupV1; + workchain: number; + config_pubkey: string; + allowed_destinations: string[]; + }; +} + +export interface WalletsStoreState { + wallets: WalletConfig[]; + selectedIdentifier: string; + biometryEnabled: boolean; + lockEnabled: boolean; + isMigrated: boolean; +} + +export class Tonkeeper { + public wallets: Map = new Map(); + public tonPrice: TonPriceManager; + + public migrationData: MultiWalletMigrationData | null = null; + + public walletSubscribers = new Set<(wallet: Wallet) => void>(); + + private tonapi: { + mainnet: TonAPI; + testnet: TonAPI; + }; + + private accountsStream: { + mainnet: AccountsStream; + testnet: AccountsStream; + }; + + private vault: Vault; + + private storage: Storage; + + public biometry: Biometry; + + public walletsStore = new State({ + wallets: [], + selectedIdentifier: '', + biometryEnabled: false, + lockEnabled: true, + isMigrated: false, + }); + + constructor(options: TonkeeperOptions) { + this.storage = options.storage; + this.vault = options.vault; + this.biometry = new Biometry(); + this.tonapi = { + mainnet: createTonApiInstance(), + testnet: createTonApiInstance(true), + }; + this.accountsStream = { + mainnet: new AccountsStream(false), + testnet: new AccountsStream(true), + }; + + this.tonPrice = new TonPriceManager(this.tonapi.mainnet, this.storage); + + this.walletsStore.persist({ + storage: this.storage, + key: 'walletsStore', + }); + } + + public get wallet() { + return this.wallets.get(this.walletsStore.data.selectedIdentifier)!; + } + + public get walletForUnlock() { + return Array.from(this.wallets.values()).find((wallet) => !wallet.isWatchOnly)!; + } + + public get biometryEnabled() { + return this.walletsStore.data.biometryEnabled; + } + + public get lockEnabled() { + return this.walletsStore.data.lockEnabled; + } + + public async init() { + try { + await Promise.all([ + this.walletsStore.rehydrate(), + this.tonPrice.rehydrate(), + this.biometry.detectTypes(), + ]); + + this.tonPrice.load(); + + if (!this.walletsStore.data.isMigrated) { + this.migrationData = await this.getMigrationData(); + } + + await Promise.all( + this.walletsStore.data.wallets.map((walletConfig) => + this.createWalletInstance(walletConfig), + ), + ); + + this.emitChangeWallet(); + } catch (err) { + console.log('TK:init', err); + } + } + + private async getMigrationData(): Promise { + const keychainName = 'mainnet_default'; + + try { + const data = await this.storage.getItem(`${keychainName}_wallet`); + + if (!data) { + return null; + } + + let biometryEnabled = false; + try { + biometryEnabled = (await this.storage.getItem('biometry_enabled')) === 'yes'; + } catch {} + + const json = JSON.parse(data); + + return { + pubkey: json.vault.tonPubkey, + keychainItemName: json.vault.name, + biometryEnabled, + lockupConfig: + json.vault.version === WalletContractVersion.LockupV1 + ? { + wallet_type: WalletContractVersion.LockupV1, + workchain: json.vault.workchain ?? 0, + config_pubkey: json.vault.configPubKey, + allowed_destinations: json.vault.allowedDestinations, + } + : undefined, + }; + } catch { + return null; + } + } + + private getNewWalletName() { + const regex = new RegExp(`${DEFAULT_WALLET_STYLE_CONFIG.name} (\\d+)`); + const lastNumber = [...this.wallets.values()].reduce((maxNumber, wallet) => { + const match = wallet.config.name.match(regex); + return match ? Math.max(maxNumber, Number(match[1])) : maxNumber; + }, 1); + + return this.wallets.size > 0 + ? `${DEFAULT_WALLET_STYLE_CONFIG.name} ${lastNumber + 1}` + : DEFAULT_WALLET_STYLE_CONFIG.name; + } + + public async createWallet(passcode: string) { + const mnemonic = (await Mnemonic.generateMnemonic(24)).join(' '); + return await this.importWallet(mnemonic, passcode, [DEFAULT_WALLET_VERSION], { + workchain: 0, + network: WalletNetwork.mainnet, + }); + } + + public async importWallet( + mnemonic: string, + passcode: string, + versions: WalletContractVersion[], + walletConfig: Pick< + WalletConfig, + 'network' | 'workchain' | 'configPubKey' | 'allowedDestinations' + >, + ): Promise { + const newWallets: WalletConfig[] = []; + + let keyPair: nacl.SignKeyPair; + for (const version of versions) { + const identifier = uuidv4(); + + keyPair = await this.vault.import(identifier, mnemonic, passcode); + + newWallets.push({ + ...DEFAULT_WALLET_STYLE_CONFIG, + ...walletConfig, + name: + versions.length > 1 + ? `${this.getNewWalletName()} ${version}` + : this.getNewWalletName(), + version, + type: WalletType.Regular, + pubkey: Buffer.from(keyPair.publicKey).toString('hex'), + identifier, + }); + } + const versionsOrder = Object.values(WalletContractVersion); + + const sortedWallets = newWallets.sort((a, b) => { + const indexA = versionsOrder.indexOf(a.version); + const indexB = versionsOrder.indexOf(b.version); + return indexA - indexB; + }); + + this.walletsStore.set(({ wallets }) => ({ wallets: [...wallets, ...sortedWallets] })); + const walletsInstances = await Promise.all( + sortedWallets.map((wallet) => this.createWalletInstance(wallet)), + ); + walletsInstances.map((instance) => instance.tonProof.obtainProof(keyPair)); + + this.setWallet(walletsInstances[0]); + + return walletsInstances.map((item) => item.identifier); + } + + public async getWalletsInfo(mnemonic: string, isTestnet: boolean) { + const keyPair = await Mnemonic.mnemonicToKeyPair(mnemonic.split(' ')); + + const pubkey = Buffer.from(keyPair.publicKey).toString('hex'); + + const tonapi = isTestnet ? this.tonapi.testnet : this.tonapi.mainnet; + + const [{ accounts }, addresses] = await Promise.all([ + tonapi.pubkeys.getWalletsByPublicKey(pubkey), + Address.fromPubkey(pubkey, isTestnet), + ]); + + if (!addresses) { + throw new Error("Can't parse pubkey"); + } + + const accountsJettons = await Promise.all( + accounts.map((account) => + tonapi.accounts.getAccountJettonsBalances({ accountId: account.address }), + ), + ); + + const versionByAddress = Object.keys(addresses).reduce( + (acc, version) => ({ ...acc, [addresses[version].raw]: version }), + {}, + ); + + const wallets = accounts.map( + (account, index): ImportWalletInfo => ({ + version: versionByAddress[account.address], + address: account.address, + balance: account.balance, + tokens: accountsJettons[index].balances.length > 0, + }), + ); + + if (!wallets.some((wallet) => wallet.version === DEFAULT_WALLET_VERSION)) { + wallets.push({ + version: DEFAULT_WALLET_VERSION, + address: addresses[DEFAULT_WALLET_VERSION].raw, + balance: 0, + tokens: false, + }); + } + + const versions = Object.values(WalletContractVersion); + + return wallets.sort((a, b) => { + const indexA = versions.indexOf(a.version); + const indexB = versions.indexOf(b.version); + return indexA - indexB; + }); + } + + public async addWatchOnlyWallet(address: string) { + const rawAddress = Address.parse(address).toRaw(); + const { public_key: pubkey } = await this.tonapi.mainnet.accounts.getAccountPublicKey( + rawAddress, + ); + + const addresses = await Address.fromPubkey(pubkey, false); + + if (!addresses) { + throw new Error("Can't parse pubkey"); + } + + const versionByAddress = Object.keys(addresses).reduce( + (acc, version) => ({ ...acc, [addresses[version].raw]: version }), + {}, + ); + + const workchain = Number(rawAddress.split(':')[0]); + + const version = versionByAddress[rawAddress] as WalletContractVersion; + + const config: WalletConfig = { + ...DEFAULT_WALLET_STYLE_CONFIG, + name: this.getNewWalletName(), + identifier: uuidv4(), + network: WalletNetwork.mainnet, + type: WalletType.WatchOnly, + pubkey, + workchain, + version, + }; + + this.walletsStore.set(({ wallets }) => ({ wallets: [...wallets, config] })); + const wallet = await this.createWalletInstance(config); + this.setWallet(wallet); + + return [wallet.identifier]; + } + + public async removeWallet(identifier: string) { + try { + const nextWallet = + this.wallets.get( + Array.from(this.wallets.keys()).find((item) => item !== identifier) ?? '', + ) ?? null; + + this.walletsStore.set(({ wallets }) => ({ + wallets: wallets.filter((w) => w.identifier !== identifier), + selectedIdentifier: nextWallet?.identifier ?? '', + })); + const wallet = this.wallets.get(identifier); + wallet?.notifications.unsubscribe().catch(null); + wallet?.destroy(); + this.wallets.delete(identifier); + + if (this.wallets.size === 0) { + this.walletsStore.set({ biometryEnabled: false }); + this.vault.destroy(); + } + + this.emitChangeWallet(); + } catch (e) { + console.log('removeWallet error', e); + } + } + + public async removeAllWallets() { + this.walletsStore.set({ + wallets: [], + selectedIdentifier: '', + biometryEnabled: false, + }); + this.wallets.forEach((wallet) => { + wallet.notifications.unsubscribe().catch(null); + wallet.destroy(); + }); + this.wallets.clear(); + this.vault.destroy(); + this.emitChangeWallet(); + } + + private async createWalletInstance(walletConfig: WalletConfig) { + const addresses = await Address.fromPubkey( + walletConfig.pubkey, + walletConfig.network === WalletNetwork.testnet, + walletConfig.version === WalletContractVersion.LockupV1 ? walletConfig : undefined, + ); + + const accountStream = + walletConfig.network === WalletNetwork.testnet + ? this.accountsStream.testnet + : this.accountsStream.mainnet; + + const wallet = new Wallet( + walletConfig, + addresses!, + this.storage, + this.tonPrice, + accountStream, + ); + + await wallet.rehydrate(); + + InteractionManager.runAfterInteractions(() => { + wallet.preload(); + }); + + this.wallets.set(wallet.identifier, wallet); + + return wallet; + } + + public async updateWallet( + config: Partial, + passedIdentifiers?: string[], + ) { + try { + if (!this.wallet) { + return; + } + + const identifiers = passedIdentifiers ?? [this.wallet.identifier]; + + const updatedWallets = this.walletsStore.data.wallets.map( + (wallet): WalletConfig => { + if (identifiers.includes(wallet.identifier)) { + return { + ...wallet, + ...config, + name: + identifiers.length > 1 + ? `${config.name} ${wallet.version}` + : config.name ?? wallet.name, + }; + } + return wallet; + }, + ); + + this.walletsStore.set({ wallets: updatedWallets }); + + identifiers.forEach((identifier) => { + const currentConfig = updatedWallets.find( + (item) => item.identifier === identifier, + ); + const wallet = this.wallets.get(identifier); + if (wallet && currentConfig) { + wallet.setConfig(currentConfig); + } + }); + + this.emitChangeWallet(); + } catch {} + } + + public onChangeWallet(subscriber: (wallet: Wallet) => void) { + this.walletSubscribers.add(subscriber); + return () => { + this.walletSubscribers.delete(subscriber); + }; + } + + private emitChangeWallet() { + this.walletSubscribers.forEach((subscriber) => subscriber(this.wallet)); + } + + public switchWallet(identifier: string) { + const wallet = this.wallets.get(identifier); + this.setWallet(wallet!); + } + + private setWallet(wallet: Wallet | null) { + this.walletsStore.set({ selectedIdentifier: wallet?.identifier ?? '' }); + this.emitChangeWallet(); + } + + public async enableNotificationsForAll(identifiers: string[]) { + for (const identifier of identifiers) { + const wallet = this.wallets.get(identifier)!; + await wallet.notifications.subscribe(); + } + } + + public getWalletByAddress(address: string) { + const isTestnet = Address.isTestnet(address); + return Array.from(this.wallets.values()).find( + (wallet) => + wallet.isTestnet === isTestnet && + Address.compare(wallet.address.ton.raw, address), + ); + } + + public setMigrated() { + console.log('migrated'); + this.walletsStore.set({ isMigrated: true }); + } + + public saveLastBackupTimestampAll(identifiers: string[], dismissSetup = false) { + identifiers.forEach((identifier) => { + const wallet = this.wallets.get(identifier); + wallet?.saveLastBackupTimestamp(); + if (dismissSetup) { + wallet?.dismissSetup(); + } + }); + } + + public async enableBiometry(passcode: string) { + await this.vault.setupBiometry(passcode); + + this.walletsStore.set({ biometryEnabled: true }); + } + + public async disableBiometry() { + try { + await this.vault.removeBiometry(); + } catch {} + + this.walletsStore.set({ biometryEnabled: false }); + } + + public async enableLock() { + this.walletsStore.set({ lockEnabled: true }); + } + + public async disableLock() { + this.walletsStore.set({ lockEnabled: false }); + } +} diff --git a/packages/mobile/src/wallet/Wallet/Wallet.ts b/packages/mobile/src/wallet/Wallet/Wallet.ts new file mode 100644 index 000000000..d90c85f0a --- /dev/null +++ b/packages/mobile/src/wallet/Wallet/Wallet.ts @@ -0,0 +1,146 @@ +import { AddressesByVersion } from '@tonkeeper/core/src/formatters/Address'; + +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { TonPriceManager } from '../managers/TonPriceManager'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { WalletConfig } from '../WalletTypes'; +import { WalletContent } from './WalletContent'; +import { AppState, AppStateStatus, NativeEventSubscription } from 'react-native'; +import { AccountsStream } from '../streaming'; + +export interface WalletStatusState { + isReloading: boolean; + isLoading: boolean; + updatedAt: number; +} + +export interface WalletSetupState { + lastBackupAt: number | null; + setupDismissed: boolean; +} + +export class Wallet extends WalletContent { + static readonly INITIAL_STATUS_STATE: WalletStatusState = { + isReloading: false, + isLoading: false, + updatedAt: Date.now(), + }; + + static readonly INITIAL_SETUP_STATE: WalletSetupState = { + lastBackupAt: null, + setupDismissed: false, + }; + + private stopListenTransactions: Function | null = null; + private appStateListener: NativeEventSubscription | null = null; + private prevAppState: AppStateStatus = 'active'; + private lastTimeAppActive = Date.now(); + + public status = new State(Wallet.INITIAL_STATUS_STATE); + + public setup = new State(Wallet.INITIAL_SETUP_STATE); + + constructor( + public config: WalletConfig, + public tonAllAddresses: AddressesByVersion, + protected storage: Storage, + protected tonPrice: TonPriceManager, + private accountStream: AccountsStream, + ) { + super(config, tonAllAddresses, storage, tonPrice); + + this.status.persist({ + partialize: ({ updatedAt }) => ({ updatedAt }), + storage: this.storage, + key: `${this.persistPath}/status`, + }); + + this.setup.persist({ + storage: this.storage, + key: `${this.persistPath}/setup`, + }); + + this.listenTransactions(); + this.listenAppState(); + } + + public saveLastBackupTimestamp() { + this.setup.set({ lastBackupAt: Date.now() }); + } + + public dismissSetup() { + this.setup.set({ setupDismissed: true }); + } + + public async rehydrate() { + await super.rehydrate(); + + await this.setup.rehydrate(); + this.status.rehydrate(); + } + + public async preload() { + if (this.status.data.isLoading) { + return; + } + this.logger.debug('preload wallet data'); + try { + this.status.set({ isLoading: true }); + await super.preload(); + this.status.set({ isLoading: false, updatedAt: Date.now() }); + } catch { + this.status.set({ isLoading: false }); + } + } + + public async reload() { + if (this.status.data.isReloading) { + return; + } + this.logger.debug('reload wallet data'); + try { + this.status.set({ isReloading: true }); + this.tonPrice.load(); + await super.reload(); + this.status.set({ isReloading: false, updatedAt: Date.now() }); + } catch { + this.status.set({ isReloading: false }); + } + } + + private listenTransactions() { + this.stopListenTransactions = this.accountStream.subscribe( + this.address.ton.raw, + (event) => { + this.logger.debug('transaction event', event.params.tx_hash); + setTimeout(() => { + this.preload(); + }, 300); + }, + ); + } + + private listenAppState() { + this.appStateListener = AppState.addEventListener('change', (nextAppState) => { + // close transactions listener if app was in background + if (nextAppState === 'background') { + this.lastTimeAppActive = Date.now(); + } + // reload data if app was in background more than 5 minutes + if (nextAppState === 'active' && this.prevAppState === 'background') { + if (Date.now() - this.lastTimeAppActive > 1000 * 60 * 5) { + this.preload(); + } + } + + this.prevAppState = nextAppState; + }); + } + + public destroy() { + this.logger.warn('destroy wallet'); + this.tonProof.destroy(); + this.appStateListener?.remove(); + this.stopListenTransactions?.(); + } +} diff --git a/packages/mobile/src/wallet/Wallet/WalletBase.ts b/packages/mobile/src/wallet/Wallet/WalletBase.ts new file mode 100644 index 000000000..5f2d94703 --- /dev/null +++ b/packages/mobile/src/wallet/Wallet/WalletBase.ts @@ -0,0 +1,97 @@ +import { TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { + WalletAddress, + WalletConfig, + WalletContractVersion, + WalletNetwork, + WalletType, +} from '../WalletTypes'; +import { Address, AddressesByVersion } from '@tonkeeper/core/src/formatters/Address'; +import { + createBatteryApiInstance, + createTonApiInstance, + createTronApiInstance, +} from '../utils'; +import { BatteryAPI } from '@tonkeeper/core/src/BatteryAPI'; +import { Storage, TronAPI } from '@tonkeeper/core'; +import { TronService } from '@tonkeeper/core/src/TronService'; +import { NamespacedLogger, logger } from '$logger'; + +export class WalletBase { + public identifier: string; + public pubkey: string; + public address: WalletAddress; + protected persistPath: string; + + public tronService: TronService; + + public tonapi: TonAPI; + protected batteryapi: BatteryAPI; + protected tronapi: TronAPI; + + protected logger: NamespacedLogger; + + constructor( + public config: WalletConfig, + public tonAllAddresses: AddressesByVersion, + protected storage: Storage, + ) { + this.identifier = config.identifier; + this.persistPath = this.identifier; + this.pubkey = config.pubkey; + + const tonAddress = Address.parse(this.tonAllAddresses[config.version].raw, { + bounceable: false, + }).toAll({ + testOnly: config.network === WalletNetwork.testnet, + }); + + this.address = { + tron: { proxy: '', owner: '' }, + ton: tonAddress, + }; + + this.logger = logger.extend(`Wallet ${this.address.ton.short}`); + + this.tonapi = createTonApiInstance(this.isTestnet); + this.batteryapi = createBatteryApiInstance(this.isTestnet); + this.tronapi = createTronApiInstance(this.isTestnet); + + this.tronService = new TronService(this.address, this.tronapi); + } + + public setConfig(config: WalletConfig) { + this.config = config; + } + + public isV4() { + return this.config.version === WalletContractVersion.v4R2; + } + + public get isLockup() { + return this.config.version === WalletContractVersion.LockupV1; + } + + public get isTestnet() { + return this.config.network === WalletNetwork.testnet; + } + + public get isWatchOnly() { + return this.config.type === WalletType.WatchOnly; + } + + public getLockupConfig() { + return { + wallet_type: this.config.version, + workchain: this.config.workchain, + config_pubkey: this.config.configPubKey, + allowed_destinations: this.config.allowedDestinations, + }; + } + + public async getWalletInfo() { + return await this.tonapi.accounts.getAccount(this.address.ton.raw); + } + + protected async rehydrate() {} +} diff --git a/packages/mobile/src/wallet/Wallet/WalletContent.ts b/packages/mobile/src/wallet/Wallet/WalletContent.ts new file mode 100644 index 000000000..c1fd7c48c --- /dev/null +++ b/packages/mobile/src/wallet/Wallet/WalletContent.ts @@ -0,0 +1,217 @@ +import { Address, AddressesByVersion } from '@tonkeeper/core/src/formatters/Address'; + +import { ActivityList } from '../Activity/ActivityList'; +import { NftsManager } from '../managers/NftsManager'; +import { SubscriptionsManager } from '../managers/SubscriptionsManager'; + +import { BalancesManager } from '../managers/BalancesManager'; +import { ActivityLoader } from '../Activity/ActivityLoader'; +import { TonActivityList } from '../Activity/TonActivityList'; +import { JettonActivityList } from '../Activity/JettonActivityList'; +import { TonInscriptions } from '../managers/TonInscriptions'; +import { JettonsManager } from '../managers/JettonsManager'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { + TokenApprovalManager, + TokenApprovalStatus, +} from '../managers/TokenApprovalManager'; +import { TonPriceManager } from '../managers/TonPriceManager'; +import { StakingManager } from '../managers/StakingManager'; +import { WalletConfig } from '../WalletTypes'; +import { WalletBase } from './WalletBase'; +import BigNumber from 'bignumber.js'; +import { BatteryManager } from '../managers/BatteryManager'; +import { NotificationsManager } from '../managers/NotificationsManager'; +import { TonProofManager } from '../managers/TonProofManager'; +import { JettonVerification } from '../models/JettonBalanceModel'; +import { CardsManager } from '$wallet/managers/CardsManager'; + +export interface WalletStatusState { + isReloading: boolean; + isLoading: boolean; + updatedAt: number; +} + +export class WalletContent extends WalletBase { + public activityLoader: ActivityLoader; + public tonProof: TonProofManager; + public tokenApproval: TokenApprovalManager; + public balances: BalancesManager; + public nfts: NftsManager; + public jettons: JettonsManager; + public tonInscriptions: TonInscriptions; + public staking: StakingManager; + public subscriptions: SubscriptionsManager; + public battery: BatteryManager; + public notifications: NotificationsManager; + public activityList: ActivityList; + public tonActivityList: TonActivityList; + public jettonActivityList: JettonActivityList; + public cards: CardsManager; + + constructor( + public config: WalletConfig, + public tonAllAddresses: AddressesByVersion, + protected storage: Storage, + protected tonPrice: TonPriceManager, + ) { + super(config, tonAllAddresses, storage); + + const tonRawAddress = this.address.ton.raw; + + this.activityLoader = new ActivityLoader(tonRawAddress, this.tonapi, this.tronapi); + + this.tonProof = new TonProofManager(this.identifier, this.tonapi); + this.tokenApproval = new TokenApprovalManager(this.persistPath, this.storage); + this.balances = new BalancesManager( + this.persistPath, + tonRawAddress, + this.config, + this.tonapi, + this.storage, + ); + this.nfts = new NftsManager( + this.persistPath, + tonRawAddress, + this.tonapi, + this.storage, + ); + this.jettons = new JettonsManager( + this.persistPath, + tonRawAddress, + this.tonPrice, + this.tokenApproval, + this.tonapi, + this.storage, + ); + this.tonInscriptions = new TonInscriptions( + this.persistPath, + tonRawAddress, + this.tonapi, + this.storage, + ); + this.staking = new StakingManager( + this.persistPath, + tonRawAddress, + this.jettons, + this.tonapi, + this.storage, + ); + this.subscriptions = new SubscriptionsManager( + this.persistPath, + tonRawAddress, + this.storage, + ); + this.battery = new BatteryManager( + this.persistPath, + this.tonProof, + this.batteryapi, + this.storage, + ); + this.cards = new CardsManager( + this.persistPath, + tonRawAddress, + this.isTestnet, + this.storage, + ); + this.notifications = new NotificationsManager( + this.persistPath, + tonRawAddress, + this.isTestnet, + this.storage, + this.logger, + ); + this.activityList = new ActivityList( + this.persistPath, + this.activityLoader, + this.storage, + ); + this.tonActivityList = new TonActivityList( + this.persistPath, + this.activityLoader, + this.storage, + ); + this.jettonActivityList = new JettonActivityList( + this.persistPath, + this.activityLoader, + this.storage, + ); + } + + protected async rehydrate() { + await super.rehydrate(); + + /* + Tonproof token must exist when we call preload, + so we have to resolve promise here + */ + await this.tonProof.rehydrate(); + this.tokenApproval.rehydrate(); + this.balances.rehydrate(); + this.nfts.rehydrate(); + this.jettons.rehydrate(); + this.tonInscriptions.rehydrate(); + this.staking.rehydrate(); + this.subscriptions.rehydrate(); + this.battery.rehydrate(); + this.notifications.rehydrate(); + this.activityList.rehydrate(); + this.tonActivityList.rehydrate(); + this.jettonActivityList.rehydrate(); + this.cards.rehydrate(); + } + + protected async preload() { + await Promise.all([ + this.balances.load(), + this.nfts.load(), + this.jettons.load(), + this.tonInscriptions.load(), + this.staking.load(), + this.subscriptions.load(), + this.battery.load(), + this.activityList.load(), + this.cards.load(), + ]); + } + + public async reload() { + await Promise.all([ + this.balances.reload(), + this.nfts.reload(), + this.jettons.reload(), + this.tonInscriptions.load(), + this.staking.reload(), + this.subscriptions.reload(), + this.battery.load(), + this.activityList.reload(), + this.cards.load(), + ]); + } + + public get totalFiat() { + const ton = new BigNumber(this.balances.state.data.ton).multipliedBy( + this.tonPrice.state.data.ton.fiat, + ); + const jettons = this.jettons.state.data.jettonBalances.reduce((total, jetton) => { + const isBlacklisted = jetton.verification === JettonVerification.BLACKLIST; + const approvalStatus = + this.tokenApproval.state.data.tokens[Address.parse(jetton.jettonAddress).toRaw()]; + if ( + (isBlacklisted && !approvalStatus) || + approvalStatus?.current === TokenApprovalStatus.Declined + ) { + return total; + } + const rate = + this.jettons.state.data.jettonRates[Address.parse(jetton.jettonAddress).toRaw()]; + return rate + ? total.plus(new BigNumber(jetton.balance).multipliedBy(rate.fiat)) + : total; + }, new BigNumber(0)); + const staking = new BigNumber(this.staking.state.data.stakingBalance).multipliedBy( + this.tonPrice.state.data.ton.fiat, + ); + return ton.plus(jettons).plus(staking).toString(); + } +} diff --git a/packages/mobile/src/wallet/Wallet/index.ts b/packages/mobile/src/wallet/Wallet/index.ts new file mode 100644 index 000000000..7df19835e --- /dev/null +++ b/packages/mobile/src/wallet/Wallet/index.ts @@ -0,0 +1 @@ +export * from './Wallet'; diff --git a/packages/mobile/src/wallet/WalletTypes.ts b/packages/mobile/src/wallet/WalletTypes.ts new file mode 100644 index 000000000..890850aea --- /dev/null +++ b/packages/mobile/src/wallet/WalletTypes.ts @@ -0,0 +1,87 @@ +import { AddressFormats } from '@tonkeeper/core/src/formatters/Address'; +import { WalletCurrency } from '@tonkeeper/core/src/utils/AmountFormatter/FiatCurrencyConfig'; +import { WalletColor } from '@tonkeeper/uikit'; + +export type TonFriendlyAddress = string; +export type TonRawAddress = string; + +export type TonAddress = { + version: WalletContractVersion; + friendly: TonFriendlyAddress; + raw: TonRawAddress; +}; + +export enum WalletNetwork { + mainnet = -239, + testnet = -3, +} + +export enum WalletType { + Regular = 'Regular', + Lockup = 'Lockup', + WatchOnly = 'WatchOnly', +} + +export enum WalletContractVersion { + v4R2 = 'v4R2', + v4R1 = 'v4R1', + v3R2 = 'v3R2', + v3R1 = 'v3R1', + LockupV1 = 'lockup-0.1', +} + +export type TronAddresses = { + proxy: string; + owner: string; +}; + +export type WalletAddress = { + tron?: TronAddresses; + ton: AddressFormats; +}; + +export type StoreWalletInfo = { + pubkey: string; + currency: WalletCurrency; + network: WalletNetwork; + kind: WalletType; +}; + +export type TonWalletState = { + address: TonAddress; + allAddresses: { [key in WalletContractVersion]: TonAddress }; +}; + +export interface TokenRate { + fiat: number; + ton: number; + usd: number; + diff_24h: string; +} + +export interface WalletStyleConfig { + name: string; + color: WalletColor; + emoji: string; +} + +export interface WalletConfig extends WalletStyleConfig { + identifier: string; + pubkey: string; + network: WalletNetwork; + type: WalletType; + version: WalletContractVersion; + workchain: number; + /** lockup */ + allowedDestinations?: string; + configPubKey?: string; +} + +export interface ImportWalletInfo { + version: WalletContractVersion; + address: string; + balance: number; + tokens: boolean; +} + +export type WithWalletIdentifier = T & { walletIdentifier: string }; diff --git a/packages/mobile/src/wallet/constants.ts b/packages/mobile/src/wallet/constants.ts new file mode 100644 index 000000000..1083e6e0d --- /dev/null +++ b/packages/mobile/src/wallet/constants.ts @@ -0,0 +1,11 @@ +import { WalletColor } from '@tonkeeper/uikit/src/utils/walletColor'; +import { WalletContractVersion, WalletStyleConfig } from './WalletTypes'; +import { t } from '@tonkeeper/shared/i18n'; + +export const DEFAULT_WALLET_STYLE_CONFIG: WalletStyleConfig = { + name: t('wallet_title'), + color: WalletColor.SteelGray, + emoji: '😀', +}; + +export const DEFAULT_WALLET_VERSION = WalletContractVersion.v4R2; diff --git a/packages/mobile/src/wallet/hooks/index.ts b/packages/mobile/src/wallet/hooks/index.ts new file mode 100644 index 000000000..a4c7082e2 --- /dev/null +++ b/packages/mobile/src/wallet/hooks/index.ts @@ -0,0 +1 @@ +export * from './useCardsState'; diff --git a/packages/mobile/src/wallet/hooks/useCardsState.ts b/packages/mobile/src/wallet/hooks/useCardsState.ts new file mode 100644 index 000000000..71d0627e4 --- /dev/null +++ b/packages/mobile/src/wallet/hooks/useCardsState.ts @@ -0,0 +1,13 @@ +import { useRef } from 'react'; +import { State } from '@tonkeeper/core'; +import { useWallet } from '@tonkeeper/shared/hooks'; +import { useExternalState } from '@tonkeeper/shared/hooks/useExternalState'; +import { CardsManager, CardsState } from '$wallet/managers/CardsManager'; + +export const useCardsState = () => { + const wallet = useWallet(); + + const initialState = useRef(new State(CardsManager.INITIAL_STATE)).current; + + return useExternalState(wallet?.cards.state ?? initialState); +}; diff --git a/packages/mobile/src/wallet/index.ts b/packages/mobile/src/wallet/index.ts new file mode 100644 index 000000000..eb8cd03df --- /dev/null +++ b/packages/mobile/src/wallet/index.ts @@ -0,0 +1,12 @@ +import { AppStorage } from '@tonkeeper/shared/modules/AppStorage'; +import { Tonkeeper } from './Tonkeeper'; +import { vault } from './AppVault'; + +export const storage = new AppStorage(); + +export const tk = new Tonkeeper({ + storage, + vault, +}); + +export { vault }; diff --git a/packages/mobile/src/wallet/managers/BalancesManager.ts b/packages/mobile/src/wallet/managers/BalancesManager.ts new file mode 100644 index 000000000..0a60513ae --- /dev/null +++ b/packages/mobile/src/wallet/managers/BalancesManager.ts @@ -0,0 +1,134 @@ +import { TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { + TonRawAddress, + WalletConfig, + WalletContractVersion, + WalletNetwork, +} from '../WalletTypes'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { AmountFormatter } from '@tonkeeper/core/src/utils/AmountFormatter'; +import { State } from '@tonkeeper/core/src/utils/State'; +import TonWeb from 'tonweb'; +import { config } from '$config'; +import BigNumber from 'bignumber.js'; +import { LockupWalletV1 } from 'tonweb/dist/types/contract/lockup/lockup-wallet-v1'; + +export interface BalancesState { + isReloading: boolean; + isLoading: boolean; + ton: string; + tonLocked: string; + tonRestricted: string; +} + +export class BalancesManager { + static readonly INITIAL_STATE: BalancesState = { + isReloading: false, + isLoading: false, + ton: '0', + tonLocked: '0', + tonRestricted: '0', + }; + + public state = new State(BalancesManager.INITIAL_STATE); + + constructor( + private persistPath: string, + private tonRawAddress: TonRawAddress, + private walletConfig: WalletConfig, + private tonapi: TonAPI, + private storage: Storage, + ) { + this.state.persist({ + partialize: ({ ton, tonLocked, tonRestricted }) => ({ + ton, + tonLocked, + tonRestricted, + }), + storage: this.storage, + key: `${this.persistPath}/balances`, + }); + } + + private get isLockup() { + return this.walletConfig.version === WalletContractVersion.LockupV1; + } + + public async getLockupBalances() { + try { + const isTestnet = this.walletConfig.network === WalletNetwork.testnet; + + const tonweb = new TonWeb( + new TonWeb.HttpProvider(config.get('tonEndpoint', isTestnet), { + apiKey: config.get('tonEndpointAPIKey', isTestnet), + }), + ); + + const tonPublicKey = Uint8Array.from(Buffer.from(this.walletConfig.pubkey, 'hex')); + + const tonWallet: LockupWalletV1 = new tonweb.lockupWallet.all[ + this.walletConfig.version + ](tonweb.provider, { + publicKey: tonPublicKey, + wc: this.walletConfig.workchain ?? 0, + config: { + wallet_type: this.walletConfig.version, + config_public_key: this.walletConfig.configPubKey, + allowed_destinations: this.walletConfig.allowedDestinations, + }, + }); + + const balances = await tonWallet.getBalances(); + const result = balances.map((item: number) => + AmountFormatter.fromNanoStatic(item.toString()), + ); + result[0] = new BigNumber(result[0]).minus(result[1]).minus(result[2]).toString(); + + return result; + } catch (e) { + if (e?.response?.status === 404) { + return ['0', '0', '0']; + } + + throw e; + } + } + + public async load() { + try { + this.state.set({ isLoading: true }); + + if (this.isLockup) { + const [ton, tonLocked, tonRestricted] = await this.getLockupBalances(); + + this.state.set({ isLoading: false, ton, tonLocked, tonRestricted }); + return this.state.data; + } + + const account = await this.tonapi.accounts.getAccount(this.tonRawAddress); + + this.state.set({ + isLoading: false, + ton: AmountFormatter.fromNanoStatic(account.balance), + }); + + return this.state.data; + } catch (e) { + this.state.set({ + isLoading: false, + }); + + throw e; + } + } + + public async reload() { + this.state.set({ isReloading: true }); + await this.load(); + this.state.set({ isReloading: false }); + } + + public async rehydrate() { + return this.state.rehydrate(); + } +} diff --git a/packages/@core-js/src/managers/BatteryManager.ts b/packages/mobile/src/wallet/managers/BatteryManager.ts similarity index 51% rename from packages/@core-js/src/managers/BatteryManager.ts rename to packages/mobile/src/wallet/managers/BatteryManager.ts index 2f8b9f8c8..e6434cb68 100644 --- a/packages/@core-js/src/managers/BatteryManager.ts +++ b/packages/mobile/src/wallet/managers/BatteryManager.ts @@ -1,52 +1,60 @@ -import { WalletContext, WalletIdentity } from '../Wallet'; -import { MessageConsequences } from '../TonAPI'; -import { Storage } from '../declarations/Storage'; -import { State } from '../utils/State'; +import { BatteryAPI } from '@tonkeeper/core/src/BatteryAPI'; +import { MessageConsequences } from '@tonkeeper/core/src/TonAPI'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { TonProofManager } from '$wallet/managers/TonProofManager'; export interface BatteryState { isLoading: boolean; balance?: string; } -export const batteryState = new State({ - isLoading: false, - balance: undefined, -}); - export class BatteryManager { - public state = batteryState; + public state = new State({ + isLoading: false, + balance: undefined, + }); constructor( - private ctx: WalletContext, - private identity: WalletIdentity, + private persistPath: string, + private tonProof: TonProofManager, + private batteryapi: BatteryAPI, private storage: Storage, ) { this.state.persist({ partialize: ({ balance }) => ({ balance }), storage: this.storage, - key: 'battery', + key: `${this.persistPath}/battery`, }); } public async fetchBalance() { try { + if (!this.tonProof.tonProofToken) { + throw new Error('No proof token'); + } this.state.set({ isLoading: true }); - const data = await this.ctx.batteryapi.getBalance({ + const data = await this.batteryapi.getBalance({ headers: { - 'X-TonConnect-Auth': this.identity.tonProof, + 'X-TonConnect-Auth': this.tonProof.tonProofToken, }, }); this.state.set({ isLoading: false, balance: data.balance }); } catch (err) { + this.state.set({ isLoading: false, balance: '0' }); return null; } } public async getExcessesAccount() { try { - const data = await this.ctx.batteryapi.getConfig({ + if (!this.tonProof.tonProofToken) { + throw new Error('No proof token'); + } + + const data = await this.batteryapi.getConfig({ headers: { - 'X-TonConnect-Auth': this.identity.tonProof, + 'X-TonConnect-Auth': this.tonProof.tonProofToken, }, }); @@ -58,11 +66,15 @@ export class BatteryManager { public async applyPromo(promoCode: string) { try { - const data = await this.ctx.batteryapi.promoCode.promoCodeBatteryPurchase( + if (!this.tonProof.tonProofToken) { + throw new Error('No proof token'); + } + + const data = await this.batteryapi.promoCode.promoCodeBatteryPurchase( { promo_code: promoCode }, { headers: { - 'X-TonConnect-Auth': this.identity.tonProof, + 'X-TonConnect-Auth': this.tonProof.tonProofToken, }, }, ); @@ -79,11 +91,15 @@ export class BatteryManager { public async makeIosPurchase(transactions: { id: string }[]) { try { - const data = await this.ctx.batteryapi.ios.iosBatteryPurchase( + if (!this.tonProof.tonProofToken) { + throw new Error('No proof token'); + } + + const data = await this.batteryapi.ios.iosBatteryPurchase( { transactions: transactions }, { headers: { - 'X-TonConnect-Auth': this.identity.tonProof, + 'X-TonConnect-Auth': this.tonProof.tonProofToken, }, }, ); @@ -98,11 +114,15 @@ export class BatteryManager { public async makeAndroidPurchase(purchases: { token: string; product_id: string }[]) { try { - const data = await this.ctx.batteryapi.android.androidBatteryPurchase( + if (!this.tonProof.tonProofToken) { + throw new Error('No proof token'); + } + + const data = await this.batteryapi.android.androidBatteryPurchase( { purchases }, { headers: { - 'X-TonConnect-Auth': this.identity.tonProof, + 'X-TonConnect-Auth': this.tonProof.tonProofToken, }, }, ); @@ -117,11 +137,15 @@ export class BatteryManager { public async sendMessage(boc: string) { try { - await this.ctx.batteryapi.sendMessage( + if (!this.tonProof.tonProofToken) { + throw new Error('No proof token'); + } + + await this.batteryapi.sendMessage( { boc }, { headers: { - 'X-TonConnect-Auth': this.identity.tonProof, + 'X-TonConnect-Auth': this.tonProof.tonProofToken, }, format: 'text', }, @@ -135,11 +159,15 @@ export class BatteryManager { public async emulate(boc: string): Promise { try { - return await this.ctx.batteryapi.emulate.emulateMessageToWallet( + if (!this.tonProof.tonProofToken) { + throw new Error('No proof token'); + } + + return await this.batteryapi.emulate.emulateMessageToWallet( { boc }, { headers: { - 'X-TonConnect-Auth': this.identity.tonProof, + 'X-TonConnect-Auth': this.tonProof.tonProofToken, }, }, ); @@ -148,6 +176,10 @@ export class BatteryManager { } } + public async load() { + return this.fetchBalance(); + } + public async rehydrate() { return this.state.rehydrate(); } diff --git a/packages/mobile/src/wallet/managers/CardsManager.tsx b/packages/mobile/src/wallet/managers/CardsManager.tsx new file mode 100644 index 000000000..085cf85f6 --- /dev/null +++ b/packages/mobile/src/wallet/managers/CardsManager.tsx @@ -0,0 +1,101 @@ +import { Storage, State, Address } from '@tonkeeper/core'; +import { config } from '$config'; +import { TonRawAddress } from '$wallet/WalletTypes'; + +export enum CardKind { + VIRTUAL = 'virtual', +} + +export interface AccountCard { + lastFourDigits?: string | null; + productId: string; + personalizationCode: string; + provider: string; + kind: string; +} + +export interface AccountState { + id: string; + address: string; + state: string; + name: string; + balance: string; + partner: string; + tzOffset: number; + cards: AccountCard[]; + contract: string; + network: 'ton-mainnet' | 'ton-testnet'; +} + +export interface CardsState { + onboardBannerDismissed: boolean; + accounts: AccountState[]; + accountsLoading: boolean; +} + +export class CardsManager { + static readonly INITIAL_STATE: CardsState = { + onboardBannerDismissed: false, + accounts: [], + accountsLoading: false, + }; + public state = new State(CardsManager.INITIAL_STATE); + + constructor( + private persistPath: string, + private tonRawAddress: TonRawAddress, + private isTestnet: boolean, + private storage: Storage, + ) { + this.state.persist({ + partialize: ({ onboardBannerDismissed, accounts }) => ({ + onboardBannerDismissed, + accounts, + }), + storage: this.storage, + key: `${this.persistPath}/cards`, + }); + } + + public async fetchAccount() { + try { + this.state.set({ accountsLoading: true }); + const resp = await fetch(`${config.get('holdersService')}/v2/public/accounts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + walletKind: 'tonkeeper', + network: this.isTestnet ? 'ton-testnet' : 'ton-mainnet', + // Holder's API works only with user-friendly bounceable address + address: new Address(this.tonRawAddress).toString({ + urlSafe: true, + testOnly: this.isTestnet, + bounceable: true, + }), + }), + }); + const data = await resp.json(); + this.state.set({ accountsLoading: false, accounts: data.accounts }); + } catch { + this.state.set({ accountsLoading: false }); + } + } + + public async load() { + return await this.fetchAccount(); + } + + public async dismissOnboardBanner() { + this.state.set({ onboardBannerDismissed: true }); + } + + public async rehydrate() { + return this.state.rehydrate(); + } + + public async clear() { + return this.state.clear(); + } +} diff --git a/packages/mobile/src/wallet/managers/JettonsManager.ts b/packages/mobile/src/wallet/managers/JettonsManager.ts new file mode 100644 index 000000000..ffe8c6a20 --- /dev/null +++ b/packages/mobile/src/wallet/managers/JettonsManager.ts @@ -0,0 +1,184 @@ +import { JettonVerificationType, TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { TokenRate, TonRawAddress } from '../WalletTypes'; +import { Logger } from '@tonkeeper/core/src/utils/Logger'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { JettonBalanceModel } from '../models/JettonBalanceModel'; +import { Address } from '@tonkeeper/core/src/formatters/Address'; +import { TokenApprovalManager } from './TokenApprovalManager'; +import { TonPriceManager } from './TonPriceManager'; +import BigNumber from 'bignumber.js'; +import { AmountFormatter } from '@tonkeeper/core'; +import { sortByPrice } from '@tonkeeper/core/src/utils/jettons'; + +export type JettonsState = { + jettonBalances: JettonBalanceModel[]; + jettonRates: Record; + error?: string | null; + isReloading: boolean; + isLoading: boolean; +}; + +export class JettonsManager { + static readonly INITIAL_STATE: JettonsState = { + isReloading: false, + isLoading: false, + jettonBalances: [], + jettonRates: {}, + error: null, + }; + + public state = new State(JettonsManager.INITIAL_STATE); + + constructor( + private persistPath: string, + private tonRawAddress: TonRawAddress, + private tonPrice: TonPriceManager, + private tokenApproval: TokenApprovalManager, + private tonapi: TonAPI, + private storage: Storage, + ) { + this.state.persist({ + partialize: ({ jettonBalances, jettonRates }) => ({ jettonBalances, jettonRates }), + storage: this.storage, + key: `${this.persistPath}/jettons`, + }); + } + + public async loadRate(address: string) { + const jettonAddress = new Address(address).toRaw(); + const currency = this.tonPrice.state.data.currency.toUpperCase(); + + try { + const response = await this.tonapi.rates.getRates({ + tokens: jettonAddress, + currencies: ['TON', 'USD', currency].join(','), + }); + + const rate = response.rates[jettonAddress]; + + this.state.set({ + ...this.state.data.jettonRates, + [jettonAddress]: { + fiat: rate?.prices![currency], + usd: rate?.prices!.USD, + ton: rate?.prices!.TON, + diff_24h: rate?.diff_24h![currency], + }, + }); + } catch {} + } + + public async load() { + try { + this.state.set({ isLoading: true, error: null }); + + const currency = this.tonPrice.state.data.currency.toUpperCase(); + + const accountJettons = await this.tonapi.accounts.getAccountJettonsBalances({ + accountId: this.tonRawAddress, + currencies: ['TON', 'USD', currency].join(','), + }); + + const jettonBalances = accountJettons.balances + .filter((item) => { + return item.balance !== '0'; + }) + .sort((a, b) => { + // Unverified or blacklisted tokens have to be at the end of array + if ( + [JettonVerificationType.None, JettonVerificationType.Blacklist].includes( + a.jetton.verification, + ) + ) { + return [ + JettonVerificationType.None, + JettonVerificationType.Blacklist, + ].includes(b.jetton.verification) + ? sortByPrice(a, b) + : 1; + } + + if ( + [JettonVerificationType.None, JettonVerificationType.Blacklist].includes( + b.jetton.verification, + ) + ) { + return -1; + } + + return sortByPrice(a, b); + }) + .map((item) => { + return new JettonBalanceModel(item); + }); + + // Move Token to pending if name or symbol changed + const jettonBalancesMap = new Map(jettonBalances.map((b) => [b.jettonAddress, b])); + + this.state.data.jettonBalances.forEach((balance) => { + const newBalance = jettonBalancesMap.get(balance.jettonAddress); + if ( + newBalance && + (balance.metadata.name !== newBalance.metadata.name || + balance.metadata.symbol !== newBalance.metadata.symbol) + ) { + this.tokenApproval.removeTokenStatus(balance.jettonAddress); + } + }); + + const jettonRates = accountJettons.balances.reduce( + (acc, item) => { + if (!item.price) { + return acc; + } + + return { + ...acc, + [item.jetton.address]: { + fiat: item.price?.prices![currency], + usd: item.price?.prices!.USD, + ton: item.price?.prices!.TON, + diff_24h: item.price?.diff_24h![currency], + }, + }; + }, + {}, + ); + + this.state.set({ + isLoading: false, + jettonBalances, + jettonRates: { ...this.state.data.jettonRates, ...jettonRates }, + }); + } catch (err) { + const message = `[JettonsManager]: ${Logger.getErrorMessage(err)}`; + console.log(message); + this.state.set({ + isLoading: false, + error: message, + }); + } + } + + public getLoadedJetton(jettonAddress: string) { + return this.state.data.jettonBalances.find((item) => + Address.compare(item.jettonAddress, jettonAddress), + ); + } + + public async rehydrate() { + return this.state.rehydrate(); + } + + public async reload() { + this.state.set({ isReloading: true }); + await this.load(); + this.state.set({ isReloading: false }); + } + + public reset() { + this.state.clear(); + this.state.clearPersist(); + } +} diff --git a/packages/mobile/src/wallet/managers/NftsManager.tsx b/packages/mobile/src/wallet/managers/NftsManager.tsx new file mode 100644 index 000000000..d28dbf4de --- /dev/null +++ b/packages/mobile/src/wallet/managers/NftsManager.tsx @@ -0,0 +1,208 @@ +import { CustomNftItem, NftImage } from '@tonkeeper/core/src/TonAPI/CustomNftItems'; +import { Address } from '@tonkeeper/core/src/formatters/Address'; +import { NftItem, TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { TonRawAddress } from '$wallet/WalletTypes'; + +export type NftsState = { + nfts: Record; + selectedDiamond: NftItem | null; + accountNfts: Record; + isReloading: boolean; + isLoading: boolean; +}; + +export class NftsManager { + static readonly INITIAL_STATE: NftsState = { + nfts: {}, + selectedDiamond: null, + accountNfts: {}, + isReloading: false, + isLoading: false, + }; + + public state = new State(NftsManager.INITIAL_STATE); + + constructor( + private persistPath: string, + private tonRawAddress: TonRawAddress, + private tonapi: TonAPI, + private storage: Storage, + ) { + this.state.persist({ + partialize: ({ accountNfts, selectedDiamond }) => ({ + accountNfts, + selectedDiamond, + }), + storage: this.storage, + key: `${this.persistPath}/nfts`, + }); + } + + public setSelectedDiamond(nftAddress: string | null) { + this.state.set(({ accountNfts }) => ({ + selectedDiamond: nftAddress ? accountNfts[Address.parse(nftAddress).toRaw()] : null, + })); + } + + public async load() { + try { + this.state.set({ isLoading: true }); + + const response = await this.tonapi.accounts.getAccountNftItems({ + accountId: this.tonRawAddress, + indirect_ownership: true, + }); + + const accountNfts = response.nft_items.reduce>( + (acc, item) => { + if (item.metadata?.render_type !== 'hidden') { + acc[item.address] = item; + } + return acc; + }, + {}, + ); + + this.state.set({ accountNfts }); + + if (this.state.data.selectedDiamond) { + this.setSelectedDiamond(this.state.data.selectedDiamond.address); + } + } catch { + } finally { + this.state.set({ isLoading: false }); + } + } + + public reset() { + this.state.set(NftsManager.INITIAL_STATE); + } + + public async rehydrate() { + return this.state.rehydrate(); + } + + public async reload() { + this.state.set({ isReloading: true }); + await this.load(); + this.state.set({ isReloading: false }); + } + + public updateNftOwner(nftAddress: string, ownerAddress: string) { + this.state.set((state) => { + if (state.nfts[nftAddress]?.owner) { + state.nfts[nftAddress].owner!.address = ownerAddress; + } + + return state; + }); + } + + public getCachedByAddress(nftAddress: string, existingNftItem?: NftItem) { + if (existingNftItem) { + return this.makeCustomNftItem(existingNftItem); + } + + const address = new Address(nftAddress); + + const nftItem = + this.state.data.accountNfts[address.toRaw()] ?? + this.state.data.nfts[address.toRaw()]; + if (nftItem) { + return this.makeCustomNftItem(nftItem); + } + + return null; + } + + public async fetchByAddress(nftAddress: string) { + const nftItem = await this.tonapi.nfts.getNftItemByAddress(nftAddress); + + if (nftItem) { + this.state.set({ nfts: { ...this.state.data.nfts, [nftItem.address]: nftItem } }); + + const customNftItem = this.makeCustomNftItem(nftItem); + + return customNftItem; + } + + throw new Error('No nftItem'); + } + + public makeCustomNftItem(nftItem: NftItem) { + const image = (nftItem.previews ?? []).reduce( + (acc, image) => { + if (image.resolution === '5x5') { + acc.preview = image.url; + } + + if (image.resolution === '100x100') { + acc.small = image.url; + } + + if (image.resolution === '500x500') { + acc.medium = image.url; + } + + if (image.resolution === '1500x1500') { + acc.large = image.url; + } + + return acc; + }, + { + preview: null, + small: null, + medium: null, + large: null, + }, + ); + + const isDomain = !!nftItem.dns; + const isUsername = isTelegramUsername(nftItem.dns); + + const customNftItem: CustomNftItem = { + ...nftItem, + name: nftItem.metadata.name, + isUsername, + isDomain, + image, + }; + + if (customNftItem.metadata) { + customNftItem.marketplaceURL = nftItem.metadata.external_url; + } + + // Custom collection name + if (isDomain && customNftItem.collection) { + customNftItem.collection.name = 'TON DNS'; + } + + // Custom nft name + if (isDomain) { + customNftItem.name = modifyNftName(nftItem.dns)!; + } else if (!customNftItem.name) { + customNftItem.name = Address.toShort(nftItem.address); + } + + return customNftItem; + } +} + +export const domainToUsername = (name?: string) => { + return name ? '@' + name.replace('.t.me', '') : ''; +}; + +export const isTelegramUsername = (name?: string) => { + return name?.endsWith('.t.me') || false; +}; + +export const modifyNftName = (name?: string) => { + if (isTelegramUsername(name)) { + return domainToUsername(name); + } + + return name; +}; diff --git a/packages/mobile/src/wallet/managers/NotificationsManager.ts b/packages/mobile/src/wallet/managers/NotificationsManager.ts new file mode 100644 index 000000000..06c38a98a --- /dev/null +++ b/packages/mobile/src/wallet/managers/NotificationsManager.ts @@ -0,0 +1,163 @@ +import DeviceInfo from 'react-native-device-info'; +import { config } from '$config'; +import { TonRawAddress } from '../WalletTypes'; +import { Address, State, Storage, network } from '@tonkeeper/core'; +import { i18n } from '@tonkeeper/shared/i18n'; +import { isAndroid } from '$utils'; +import { PermissionsAndroid, Platform } from 'react-native'; +import messaging from '@react-native-firebase/messaging'; +import { NamespacedLogger } from '$logger'; +import { isIOS } from '@tonkeeper/uikit'; + +export interface NotificationsState { + isSubscribed: boolean; +} + +const hasGms = DeviceInfo.hasGmsSync(); + +export class NotificationsManager { + static readonly INITIAL_STATE: NotificationsState = { + isSubscribed: false, + }; + + public state = new State(NotificationsManager.INITIAL_STATE); + + public isAvailable = hasGms || isIOS; + + constructor( + private persistPath: string, + private tonRawAddress: TonRawAddress, + private isTestnet: boolean, + private storage: Storage, + private logger: NamespacedLogger, + ) { + this.state.persist({ + partialize: ({ isSubscribed }) => ({ isSubscribed }), + storage: this.storage, + key: `${this.persistPath}/notifications`, + }); + } + + public async rehydrate() { + return this.state.rehydrate(); + } + + public async subscribe() { + this.logger.debug('NotificationsManager.subscribe call'); + + const token = await this.requestUserPermissionAndGetToken(); + + if (!token) { + return false; + } + + const endpoint = `${config.get( + 'tonapiIOEndpoint', + this.isTestnet, + )}/v1/internal/pushes/plain/subscribe`; + const deviceId = DeviceInfo.getUniqueId(); + + await network.post(endpoint, { + params: { + locale: i18n.locale, + device: deviceId, + accounts: [ + { address: Address.parse(this.tonRawAddress).toFriendly({ bounceable: true }) }, + ], + token, + }, + }); + + this.state.set({ isSubscribed: true }); + + this.logger.debug('NotificationsManager.subscribe done'); + + return true; + } + + public async unsubscribe() { + this.logger.debug('NotificationsManager.unsubscribe call'); + + if (!this.state.data.isSubscribed) { + return false; + } + + const deviceId = DeviceInfo.getUniqueId(); + const endpoint = `${config.get('tonapiIOEndpoint', this.isTestnet)}/unsubscribe`; + + await network.post(endpoint, { + params: { + device: deviceId, + accounts: [ + { address: Address.parse(this.tonRawAddress).toFriendly({ bounceable: true }) }, + ], + }, + }); + + this.state.set({ isSubscribed: false }); + + this.logger.debug('NotificationsManager.unsubscribe done'); + + return true; + } + + public async getIsDenied() { + try { + if (!this.isAvailable) { + return true; + } + + const authStatus = await messaging().hasPermission(); + return authStatus === messaging.AuthorizationStatus.DENIED; + } catch { + return false; + } + } + + private async getPermission() { + if (isAndroid && +Platform.Version >= 33) { + return await PermissionsAndroid.check( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ); + } else { + const authStatus = await messaging().hasPermission(); + const enabled = + authStatus === messaging.AuthorizationStatus.AUTHORIZED || + authStatus === messaging.AuthorizationStatus.PROVISIONAL; + + return enabled; + } + } + + private async getToken() { + return await messaging().getToken(); + } + + private async requestUserPermissionAndGetToken() { + if (isAndroid && +Platform.Version >= 33) { + const status = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ); + const enabled = status === PermissionsAndroid.RESULTS.GRANTED; + + if (!enabled) { + return false; + } + } else { + const hasPermission = await this.getPermission(); + + if (!hasPermission) { + const authStatus = await messaging().requestPermission(); + const enabled = + authStatus === messaging.AuthorizationStatus.AUTHORIZED || + authStatus === messaging.AuthorizationStatus.PROVISIONAL; + + if (!enabled) { + return false; + } + } + } + + return await this.getToken(); + } +} diff --git a/packages/mobile/src/wallet/managers/StakingManager.ts b/packages/mobile/src/wallet/managers/StakingManager.ts new file mode 100644 index 000000000..72af2fef4 --- /dev/null +++ b/packages/mobile/src/wallet/managers/StakingManager.ts @@ -0,0 +1,294 @@ +import { + AccountStakingInfo, + PoolImplementationType, + PoolInfo, + TonAPI, +} from '@tonkeeper/core/src/TonAPI'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { TonRawAddress } from '../WalletTypes'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { JettonMetadata } from '../models/JettonBalanceModel'; +import { JettonsManager } from './JettonsManager'; +import BigNumber from 'bignumber.js'; +import { AmountFormatter } from '@tonkeeper/core/src/utils/AmountFormatter'; +import { isEqual } from 'lodash'; + +export interface StakingProvider { + id: PoolImplementationType; + name: string; + description: string; + url: string; + maxApy: number; + minStake: number; + socials: string[]; +} + +export type StakingInfo = Record; + +export enum StakingApiStatus { + BackgroundFetching = 'BackgroundFetching', + Refreshing = 'Refreshing', + Idle = 'Idle', +} + +export type StakingState = { + status: StakingApiStatus; + pools: PoolInfo[]; + highestApyPool: PoolInfo | null; + providers: StakingProvider[]; + stakingInfo: StakingInfo; + stakingJettons: Record; + stakingJettonsUpdatedAt: number; + stakingBalance: string; +}; + +export class StakingManager { + static readonly KNOWN_STAKING_IMPLEMENTATIONS = [ + PoolImplementationType.Whales, + PoolImplementationType.Tf, + PoolImplementationType.LiquidTF, + ]; + + static readonly INITIAL_STATE: StakingState = { + status: StakingApiStatus.Idle, + stakingInfo: {}, + stakingJettons: {}, + stakingJettonsUpdatedAt: 0, + pools: [], + providers: [], + highestApyPool: null, + stakingBalance: '0', + }; + + static calculatePoolBalance(pool: PoolInfo, stakingInfo: StakingInfo) { + const amount = new BigNumber( + AmountFormatter.fromNanoStatic(stakingInfo[pool.address]?.amount || '0'), + ); + const pendingDeposit = new BigNumber( + AmountFormatter.fromNanoStatic(stakingInfo[pool.address]?.pending_deposit || '0'), + ); + const pendingWithdraw = new BigNumber( + AmountFormatter.fromNanoStatic(stakingInfo[pool.address]?.pending_withdraw || '0'), + ); + const readyWithdraw = new BigNumber( + AmountFormatter.fromNanoStatic(stakingInfo[pool.address]?.ready_withdraw || '0'), + ); + const balance = amount + .plus(pendingDeposit) + .plus(readyWithdraw) + .plus( + pool.implementation === PoolImplementationType.LiquidTF ? pendingWithdraw : 0, + ); + + return balance; + } + + public state = new State(StakingManager.INITIAL_STATE); + + constructor( + private persistPath: string, + private tonRawAddress: TonRawAddress, + private jettons: JettonsManager, + private tonapi: TonAPI, + private storage: Storage, + ) { + this.state.persist({ + partialize: ({ status: _status, ...state }) => state, + storage: this.storage, + key: `${this.persistPath}/staking`, + }); + } + + public async load(silent?: boolean, updateIfBalanceSame = true) { + const { status } = this.state.data; + + if (status !== StakingApiStatus.Idle) { + if (status === StakingApiStatus.BackgroundFetching && !silent) { + this.state.set({ status: StakingApiStatus.Refreshing }); + } + + return; + } + + try { + this.state.set({ + status: silent + ? StakingApiStatus.BackgroundFetching + : StakingApiStatus.Refreshing, + }); + + const [poolsResponse, nominatorsResponse] = await Promise.allSettled([ + this.tonapi.staking.getStakingPools({ + available_for: this.tonRawAddress, + include_unverified: false, + }), + this.tonapi.staking.getAccountNominatorsPools(this.tonRawAddress), + ]); + + let pools = this.state.data.pools; + + let nextState: Partial = {}; + + // const tonstakersEnabled = !getFlag('disable_tonstakers'); + + if (poolsResponse.status === 'fulfilled') { + const { implementations } = poolsResponse.value; + + pools = poolsResponse.value.pools + // .filter( + // (pool) => + // tonstakersEnabled || + // pool.implementation !== PoolImplementationType.LiquidTF, + // ) + .map((pool) => { + if (pool.implementation !== PoolImplementationType.Whales) { + return pool; + } + + const cycle_start = pool.cycle_end > 0 ? pool.cycle_end - 36 * 3600 : 0; + + return { ...pool, cycle_start }; + }) + .sort((a, b) => { + if (a.name.includes('Tonkeeper') && !b.name.includes('Tonkeeper')) { + return -1; + } + + if (b.name.includes('Tonkeeper') && !a.name.includes('Tonkeeper')) { + return 1; + } + + if (a.name.includes('Tonkeeper') && b.name.includes('Tonkeeper')) { + return a.name.includes('#1') ? -1 : 1; + } + + if (a.apy === b.apy) { + return a.cycle_start > b.cycle_start ? 1 : -1; + } + + return a.apy > b.apy ? 1 : -1; + }); + + const providers = (Object.keys(implementations) as PoolImplementationType[]) + .filter((id) => pools.some((pool) => pool.implementation === id)) + .sort((a, b) => { + const indexA = StakingManager.KNOWN_STAKING_IMPLEMENTATIONS.indexOf(a); + const indexB = StakingManager.KNOWN_STAKING_IMPLEMENTATIONS.indexOf(b); + + if (indexA === -1 && indexB === -1) { + return 0; + } + + if (indexA === -1) { + return 1; + } + + if (indexB === -1) { + return -1; + } + + return indexA > indexB ? 1 : -1; + }) + .map((id): StakingProvider => { + const implementationPools = pools.filter( + (pool) => pool.implementation === id, + ); + const maxApy = Math.max(...implementationPools.map((pool) => pool.apy)); + const minStake = Math.min( + ...implementationPools.map((pool) => pool.min_stake), + ); + + return { id, maxApy, minStake, ...implementations[id] }; + }); + + const highestApyPool = pools.reduce((acc, cur) => { + if (!acc) { + return cur; + } + + return cur.apy > acc.apy ? cur : acc; + }, null); + + await Promise.all( + pools + .filter((pool) => pool.liquid_jetton_master) + .map((pool) => { + return (async () => { + if (this.state.data.stakingJettonsUpdatedAt + 3600 * 1000 > Date.now()) { + return; + } + + const [jettonInfo] = await Promise.all([ + this.tonapi.jettons.getJettonInfo(pool.liquid_jetton_master!), + this.jettons.loadRate(pool.liquid_jetton_master!), + ]); + + this.state.set((state) => ({ + stakingJettons: { + ...state.stakingJettons, + [pool.liquid_jetton_master!]: { + ...jettonInfo.metadata, + decimals: Number(jettonInfo.metadata.decimals), + }, + }, + })); + })(); + }), + ); + + this.state.set({ stakingJettonsUpdatedAt: Date.now() }); + + nextState = { + ...nextState, + pools: pools.sort((a, b) => b.apy - a.apy), + providers, + highestApyPool, + }; + } + + if (nominatorsResponse.status === 'fulfilled') { + const stakingInfo = nominatorsResponse.value.pools.reduce( + (acc, cur) => ({ ...acc, [cur.pool]: cur }), + {}, + ); + + const stakingBalance = pools.reduce((total, pool) => { + return total.plus(StakingManager.calculatePoolBalance(pool, stakingInfo)); + }, new BigNumber('0')); + + nextState = { + ...nextState, + stakingInfo, + stakingBalance: stakingBalance.toString(), + }; + } + + if ( + updateIfBalanceSame || + !isEqual(nextState.stakingInfo, this.state.data.stakingInfo) + ) { + this.state.set({ ...nextState }); + } + } catch (e) { + console.log('fetchPools error', e); + } finally { + this.state.set({ status: StakingApiStatus.Idle }); + } + } + + public async reload() { + await this.load(); + } + + public reset() { + this.state.set({ + stakingInfo: {}, + stakingBalance: '0', + status: StakingApiStatus.Idle, + }); + } + + public async rehydrate() { + return this.state.rehydrate(); + } +} diff --git a/packages/mobile/src/wallet/managers/SubscriptionsManager.ts b/packages/mobile/src/wallet/managers/SubscriptionsManager.ts new file mode 100644 index 000000000..455bedc02 --- /dev/null +++ b/packages/mobile/src/wallet/managers/SubscriptionsManager.ts @@ -0,0 +1,84 @@ +import { network } from '@tonkeeper/core/src/utils/network'; +import { TonRawAddress } from '../WalletTypes'; +import { State, Storage } from '@tonkeeper/core'; +import { config } from '$config'; + +export interface Subscription { + id?: string; + productName: string; + channelTgId: string; + amountNano: string; + intervalSec: number; + address: string; + status: string; + merchantName: string; + merchantPhoto: string; + returnUrl: string; + subscriptionId: number; + subscriptionAddress: string; + isActive?: boolean; + chargedAt: number; + fee: string; + userReturnUrl: string; +} + +export type Subscriptions = { [k: string]: Subscription }; + +export interface SubscriptionsState { + subscriptions: Subscriptions; + isLoading: boolean; +} + +export interface SubscriptionsResponse { + data: Subscriptions; +} + +export class SubscriptionsManager { + constructor( + private persistPath: string, + private tonRawAddress: TonRawAddress, + private storage: Storage, + ) { + this.state.persist({ + partialize: ({ subscriptions }) => ({ + subscriptions, + }), + storage: this.storage, + key: `${this.persistPath}/subscriptions`, + }); + } + + static readonly INITIAL_STATE: SubscriptionsState = { + subscriptions: {}, + isLoading: false, + }; + + public state = new State(SubscriptionsManager.INITIAL_STATE); + + public async load() { + try { + this.state.set({ isLoading: true }); + const { data: subscriptions } = await network.get( + `${config.get('subscriptionsHost')}/v1/subscriptions`, + { + params: { address: this.tonRawAddress }, + }, + ); + this.state.set({ isLoading: false, subscriptions: subscriptions.data }); + } catch { + this.state.set({ isLoading: false }); + } + } + + public async reload() { + await this.load(); + } + + public reset() { + this.state.set(SubscriptionsManager.INITIAL_STATE); + } + + public async rehydrate() { + return this.state.rehydrate(); + } +} diff --git a/packages/mobile/src/wallet/managers/TokenApprovalManager.ts b/packages/mobile/src/wallet/managers/TokenApprovalManager.ts new file mode 100644 index 000000000..4d2e4b8f4 --- /dev/null +++ b/packages/mobile/src/wallet/managers/TokenApprovalManager.ts @@ -0,0 +1,110 @@ +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { Address } from '@tonkeeper/core/src/formatters/Address'; + +export enum TokenApprovalStatus { + Approved = 'approved', + Declined = 'declined', +} + +export enum TokenApprovalType { + Collection = 'collection', + Token = 'token', + Inscription = 'inscription', +} + +export interface ApprovalStatus { + current: TokenApprovalStatus; + type: TokenApprovalType; + updated_at: number; + approved_meta_revision: number; +} + +export type TokenApprovalState = { + tokens: Record; + hasWatchedCollectiblesTab: boolean; +}; + +export class TokenApprovalManager { + static readonly INITIAL_STATE: TokenApprovalState = { + tokens: {}, + hasWatchedCollectiblesTab: false, + }; + + public state = new State(TokenApprovalManager.INITIAL_STATE); + + constructor(private persistPath: string, private storage: Storage) { + this.state.persist({ + storage: this.storage, + key: `${this.persistPath}/tokenApproval`, + }); + this.migrate(); + } + + removeTokenStatus(address: string) { + const { tokens } = this.state.data; + const rawAddress = Address.parse(address).toRaw(); + if (tokens[rawAddress]) { + delete tokens[rawAddress]; + this.state.set({ tokens }); + } + } + + setHasWatchedCollectiblesTab(hasWatchedCollectiblesTab: boolean) { + this.state.set({ hasWatchedCollectiblesTab }); + } + + updateTokenStatus( + identifier: string, + status: TokenApprovalStatus, + type: TokenApprovalType, + ) { + const { tokens } = this.state.data; + const token = { ...tokens[identifier] }; + + if (token) { + token.current = status; + token.updated_at = Date.now(); + + this.state.set({ tokens: { ...tokens, [identifier]: token } }); + } else { + this.state.set({ + tokens: { + ...tokens, + [identifier]: { + type, + current: status, + updated_at: Date.now(), + approved_meta_revision: 0, + }, + }, + }); + } + } + + public async rehydrate() { + return this.state.rehydrate(); + } + + public reset() { + this.state.clear(); + this.state.clearPersist(); + } + + private async migrate() { + try { + const data = await this.storage.getItem('tokenApproval'); + + if (!data) { + return; + } + + const state = JSON.parse(data).state; + + if (state) { + this.storage.removeItem('tokenApproval'); + this.state.set(state); + } + } catch {} + } +} diff --git a/packages/@core-js/src/managers/TonInscriptions.ts b/packages/mobile/src/wallet/managers/TonInscriptions.ts similarity index 62% rename from packages/@core-js/src/managers/TonInscriptions.ts rename to packages/mobile/src/wallet/managers/TonInscriptions.ts index e1dd3a23a..0ca121bc6 100644 --- a/packages/@core-js/src/managers/TonInscriptions.ts +++ b/packages/mobile/src/wallet/managers/TonInscriptions.ts @@ -1,6 +1,6 @@ -import { InscriptionBalance, TonAPI } from '../TonAPI'; -import { Storage } from '../declarations/Storage'; -import { State } from '../utils/State'; +import { InscriptionBalance, TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { State } from '@tonkeeper/core/src/utils/State'; type TonInscriptionsState = { items: InscriptionBalance[]; @@ -14,22 +14,27 @@ export class TonInscriptions { }); constructor( - private tonAddress: string, + private persistPath: string, + private tonRawAddress: string, private tonapi: TonAPI, private storage: Storage, ) { this.state.persist({ partialize: ({ items }) => ({ items }), storage: this.storage, - key: 'inscriptions', + key: `${this.persistPath}/inscriptions`, }); } - public async getInscriptions() { + public async rehydrate() { + return this.state.rehydrate(); + } + + public async load() { try { this.state.set({ isLoading: true }); const data = await this.tonapi.experimental.getAccountInscriptions({ - accountId: this.tonAddress, + accountId: this.tonRawAddress, }); this.state.set({ items: data.inscriptions.filter((inscription) => inscription.balance !== '0'), @@ -38,8 +43,4 @@ export class TonInscriptions { this.state.set({ isLoading: false }); } } - - public async preload() { - this.getInscriptions(); - } } diff --git a/packages/mobile/src/wallet/managers/TonPriceManager.ts b/packages/mobile/src/wallet/managers/TonPriceManager.ts new file mode 100644 index 000000000..f3d22dcfb --- /dev/null +++ b/packages/mobile/src/wallet/managers/TonPriceManager.ts @@ -0,0 +1,83 @@ +import { WalletCurrency } from '@tonkeeper/core/src/utils/AmountFormatter/FiatCurrencyConfig'; +import { Storage } from '@tonkeeper/core/src/declarations/Storage'; +import { State } from '@tonkeeper/core/src/utils/State'; +import { TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { TokenRate } from '../WalletTypes'; +import { NamespacedLogger, logger } from '$logger'; + +export type PricesState = { + ton: TokenRate; + updatedAt: number; + currency: WalletCurrency; +}; + +export class TonPriceManager { + public state = new State({ + ton: { + fiat: 0, + usd: 0, + ton: 0, + diff_24h: '', + }, + updatedAt: Date.now(), + currency: WalletCurrency.USD, + }); + + private logger: NamespacedLogger; + + constructor(private tonapi: TonAPI, private storage: Storage) { + this.state.persist({ + storage: this.storage, + key: 'ton_price', + }); + + this.logger = logger.extend('TonPriceManager'); + + this.migrate(); + } + + setFiatCurrency(currency: WalletCurrency) { + this.logger.debug('Setting fiat currency', currency); + this.state.clear(); + this.state.clearPersist(); + this.state.set({ currency }); + } + + public async load() { + this.logger.debug('Loading TON price'); + try { + const currency = this.state.data.currency.toUpperCase(); + const token = 'TON'; + const data = await this.tonapi.rates.getRates({ + currencies: ['TON', 'USD', currency].join(','), + tokens: token, + }); + + this.state.set({ + ton: { + fiat: data.rates[token].prices![currency], + usd: data.rates[token].prices!.USD, + ton: data.rates[token].prices!.TON, + diff_24h: data.rates[token].diff_24h![currency], + }, + updatedAt: Date.now(), + }); + } catch {} + } + + private async migrate() { + try { + const key = 'mainnet_default_primary_currency'; + const currency = (await this.storage.getItem(key)) as WalletCurrency; + + if (currency) { + this.storage.removeItem(key); + this.state.set({ currency }); + } + } catch {} + } + + public async rehydrate() { + return this.state.rehydrate(); + } +} diff --git a/packages/mobile/src/wallet/managers/TonProofManager.ts b/packages/mobile/src/wallet/managers/TonProofManager.ts new file mode 100644 index 000000000..0355b0f27 --- /dev/null +++ b/packages/mobile/src/wallet/managers/TonProofManager.ts @@ -0,0 +1,52 @@ +import nacl from 'tweetnacl'; +import * as SecureStore from 'expo-secure-store'; +import { TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { ContractService, WalletVersion } from '@tonkeeper/core'; +import { Buffer } from 'buffer'; +import { beginCell } from '@ton/core'; +import { storeStateInit } from '@ton/ton'; +import { signProofForTonkeeper } from '@tonkeeper/core/src/utils/tonProof'; + +export class TonProofManager { + public tonProofToken: string | null = null; + + constructor(public identifier: string, public tonapi: TonAPI) {} + + public async obtainProof(keyPair: nacl.SignKeyPair) { + const contract = ContractService.getWalletContract( + WalletVersion.v4R2, + Buffer.from(keyPair.publicKey), + 0, + ); + const stateInitCell = beginCell().store(storeStateInit(contract.init)).endCell(); + const rawAddress = contract.address.toRawString(); + + try { + const { payload } = await this.tonapi.tonconnect.getTonConnectPayload(); + const proof = await signProofForTonkeeper( + rawAddress, + keyPair.secretKey, + payload, + stateInitCell.toBoc({ idx: false }).toString('base64'), + ); + const { token } = await this.tonapi.wallet.tonConnectProof(proof); + + this.tonProofToken = token; + + await SecureStore.setItemAsync(`proof-${this.identifier}`, token); + } catch (err) { + console.log('TonProofManager.obtainProof', err); + return null; + } + } + + public async rehydrate() { + try { + this.tonProofToken = await SecureStore.getItemAsync(`proof-${this.identifier}`); + } catch {} + } + + public destroy() { + SecureStore.deleteItemAsync(`proof-${this.identifier}`); + } +} diff --git a/packages/@core-js/src/models/ActivityModel/ActivityModel.ts b/packages/mobile/src/wallet/models/ActivityModel/ActivityModel.ts similarity index 95% rename from packages/@core-js/src/models/ActivityModel/ActivityModel.ts rename to packages/mobile/src/wallet/models/ActivityModel/ActivityModel.ts index 6b2b16000..d37629b8f 100644 --- a/packages/@core-js/src/models/ActivityModel/ActivityModel.ts +++ b/packages/mobile/src/wallet/models/ActivityModel/ActivityModel.ts @@ -1,8 +1,4 @@ -import { AccountEvent, ActionStatusEnum } from '../../TonAPI'; import { differenceInCalendarMonths, format } from 'date-fns'; -import { toLowerCaseFirstLetter } from '../../utils/strings'; -import { TronEvent } from '../../TronAPI/TronAPIGenerated'; -import { Address } from '../../formatters/Address'; import { nanoid } from 'nanoid/non-secure'; import { AnyActionTypePayload, @@ -15,6 +11,10 @@ import { ActionItem, AnyActionItem, } from './ActivityModelTypes'; +import { AccountEvent, ActionStatusEnum } from '@tonkeeper/core/src/TonAPI'; +import { toLowerCaseFirstLetter } from '@tonkeeper/uikit'; +import { Address } from '@tonkeeper/core'; +import { TronEvent } from '@tonkeeper/core/src/TronAPI/TronAPIGenerated'; type CreateActionOptions = { source: ActionSource; @@ -114,6 +114,7 @@ export class ActivityModel { payload: action.payload, action_id: nanoid(), type: (action as any).type, + initialActionType: (action as any).type, isFirst: false, isLast: false, destination, @@ -200,7 +201,7 @@ export class ActivityModel { static defineActionDestination( ownerAddress: string, actionType: ActionType, - payload: AnyActionPayload, + payload?: AnyActionPayload, ): ActionDestination { if ( actionType === ActionType.WithdrawStake || diff --git a/packages/@core-js/src/models/ActivityModel/ActivityModelTypes.ts b/packages/mobile/src/wallet/models/ActivityModel/ActivityModelTypes.ts similarity index 96% rename from packages/@core-js/src/models/ActivityModel/ActivityModelTypes.ts rename to packages/mobile/src/wallet/models/ActivityModel/ActivityModelTypes.ts index 0e92bd842..69d1bc9e7 100644 --- a/packages/@core-js/src/models/ActivityModel/ActivityModelTypes.ts +++ b/packages/mobile/src/wallet/models/ActivityModel/ActivityModelTypes.ts @@ -1,26 +1,29 @@ -import { ReceiveTRC20Action, SendTRC20Action } from '../../TronAPI/TronAPIGenerated'; import { AccountEvent, - ActionSimplePreview, - ActionStatusEnum, - AuctionBidAction, - ContractDeployAction, - DepositStakeAction, - ElectionsDepositStakeAction, - ElectionsRecoverStakeAction, - JettonBurnAction, - JettonMintAction, - JettonSwapAction, + TonTransferAction, JettonTransferAction, NftItemTransferAction, - NftPurchaseAction, - SmartContractAction, + ContractDeployAction, SubscriptionAction, - TonTransferAction, UnSubscriptionAction, + AuctionBidAction, + NftPurchaseAction, + SmartContractAction, + JettonSwapAction, + JettonBurnAction, + JettonMintAction, + DepositStakeAction, WithdrawStakeAction, WithdrawStakeRequestAction, -} from '../../TonAPI/TonAPIGenerated'; + ElectionsRecoverStakeAction, + ElectionsDepositStakeAction, + ActionStatusEnum, + ActionSimplePreview, +} from '@tonkeeper/core/src/TonAPI'; +import { + SendTRC20Action, + ReceiveTRC20Action, +} from '@tonkeeper/core/src/TronAPI/TronAPIGenerated'; export type GroupKey = string; export type ActionId = string; diff --git a/packages/@core-js/src/models/ActivityModel/index.ts b/packages/mobile/src/wallet/models/ActivityModel/index.ts similarity index 100% rename from packages/@core-js/src/models/ActivityModel/index.ts rename to packages/mobile/src/wallet/models/ActivityModel/index.ts diff --git a/packages/mobile/src/wallet/models/JettonBalanceModel/JettonBalanceModel.ts b/packages/mobile/src/wallet/models/JettonBalanceModel/JettonBalanceModel.ts new file mode 100644 index 000000000..3574dde41 --- /dev/null +++ b/packages/mobile/src/wallet/models/JettonBalanceModel/JettonBalanceModel.ts @@ -0,0 +1,23 @@ +import { Address, AmountFormatter } from '@tonkeeper/core'; +import { JettonBalance } from '@tonkeeper/core/src/TonAPI'; +import { JettonMetadata, JettonVerification } from './types'; + +export class JettonBalanceModel { + metadata: JettonMetadata; + balance: string; + jettonAddress: string; + walletAddress: string; + verification: JettonVerification; + + constructor(jettonBalance: JettonBalance) { + this.metadata = jettonBalance.jetton; + this.balance = AmountFormatter.fromNanoStatic( + jettonBalance.balance, + jettonBalance.jetton.decimals, + ); + this.jettonAddress = new Address(jettonBalance.jetton.address).toFriendly(); + this.walletAddress = new Address(jettonBalance.wallet_address.address).toFriendly(); + this.verification = jettonBalance.jetton + .verification as unknown as JettonVerification; + } +} diff --git a/packages/mobile/src/wallet/models/JettonBalanceModel/index.ts b/packages/mobile/src/wallet/models/JettonBalanceModel/index.ts new file mode 100644 index 000000000..bbf7fb9b8 --- /dev/null +++ b/packages/mobile/src/wallet/models/JettonBalanceModel/index.ts @@ -0,0 +1,2 @@ +export * from './JettonBalanceModel'; +export * from './types'; diff --git a/packages/mobile/src/wallet/models/JettonBalanceModel/types.ts b/packages/mobile/src/wallet/models/JettonBalanceModel/types.ts new file mode 100644 index 000000000..6f8e6bed1 --- /dev/null +++ b/packages/mobile/src/wallet/models/JettonBalanceModel/types.ts @@ -0,0 +1,15 @@ +export interface JettonMetadata { + address: string; + decimals: number; + symbol?: string; + image_data?: string; + image?: string; + description?: string; + name?: string; +} + +export enum JettonVerification { + WHITELIST = 'whitelist', + NONE = 'none', + BLACKLIST = 'blacklist', +} diff --git a/packages/mobile/src/wallet/streaming/AccountsObserver.ts b/packages/mobile/src/wallet/streaming/AccountsObserver.ts new file mode 100644 index 000000000..d2923af5b --- /dev/null +++ b/packages/mobile/src/wallet/streaming/AccountsObserver.ts @@ -0,0 +1,139 @@ +import { config } from '$config'; +import { NamespacedLogger, logger } from '$logger'; + +export interface AccountTransactionEvent { + jsonrpc: '2.0'; + method: 'account_transaction'; + params: { + account_id: string; + lt: number; + tx_hash: string; + }; +} + +export type AccountsStreamEvent = AccountTransactionEvent; + +export type AccountsStreamCallback = (event: AccountsStreamEvent) => void; + +interface Subscriber { + accountId: string; + callback: AccountsStreamCallback; +} + +export class AccountsStream { + private subscribers: Set = new Set(); + private socket: WebSocket | null = null; + private lastId = 0; + private isOpened = false; + private logger: NamespacedLogger; + + constructor(private isTestnet: boolean) { + this.logger = logger.extend( + `AccountsStream ${this.isTestnet ? 'testnet' : 'mainnet'}`, + ); + } + + private open() { + const endpoint = this.isTestnet + ? config.get('tonapiTestnetWsEndpoint') + : config.get('tonapiWsEndpoint'); + + this.socket = new WebSocket( + `${endpoint}?token=${config.get('tonApiV2Key', this.isTestnet)}`, + ); + + this.socket.onopen = () => { + this.logger.info('socket opened'); + this.isOpened = true; + this.lastId = 0; + + const accountIds = new Set(); + + for (const subscriber of this.subscribers.values()) { + accountIds.add(subscriber.accountId); + } + + if (accountIds.size > 0) { + this.send('subscribe_account', Array.from(accountIds)); + } + }; + + this.socket.onmessage = (socketEvent) => { + const event = JSON.parse(socketEvent.data); + + if (event.method === 'account_transaction') { + for (const subscriber of this.subscribers.values()) { + if (subscriber.accountId === event.params.account_id) { + subscriber.callback(event); + } + } + } else if (event.result) { + this.logger.debug(event.result); + } + }; + + this.socket.onclose = () => { + this.logger.error('socket closed'); + this.socket = null; + this.isOpened = false; + + if (this.subscribers.size > 0) { + setTimeout(() => this.open(), 1000); + } + }; + } + + private send(method: string, params?: string | string[]) { + try { + if (!this.socket || !this.isOpened) { + return; + } + + this.lastId++; + + this.socket.send( + JSON.stringify({ + id: this.lastId, + jsonrpc: '2.0', + method, + params, + }), + ); + + this.logger.debug('sent', method, params); + } catch {} + } + + public subscribe(accountId: string, callback: AccountsStreamCallback) { + const subscriber = { accountId, callback }; + this.subscribers.add(subscriber); + + const accountSubscribers = [...this.subscribers.values()].filter( + (item) => item.accountId === accountId, + ); + + if (accountSubscribers.length === 1) { + this.send('subscribe_account', [accountId]); + } + + if (!this.socket) { + this.open(); + } + + return () => { + this.subscribers.delete(subscriber); + + if (this.subscribers.size === 0) { + this.socket?.close(); + } else { + const subscribersAfter = [...this.subscribers.values()].filter( + (item) => item.accountId === accountId, + ); + + if (subscribersAfter.length === 0) { + this.send('unsubscribe_account', [accountId]); + } + } + }; + } +} diff --git a/packages/mobile/src/wallet/streaming/index.ts b/packages/mobile/src/wallet/streaming/index.ts new file mode 100644 index 000000000..76f0a8074 --- /dev/null +++ b/packages/mobile/src/wallet/streaming/index.ts @@ -0,0 +1 @@ +export * from './AccountsObserver'; diff --git a/packages/mobile/src/wallet/utils.ts b/packages/mobile/src/wallet/utils.ts new file mode 100644 index 000000000..cb63ce07e --- /dev/null +++ b/packages/mobile/src/wallet/utils.ts @@ -0,0 +1,41 @@ +import { TronAPI } from '@tonkeeper/core'; +import { BatteryAPI } from '@tonkeeper/core/src/BatteryAPI'; +import { TonAPI } from '@tonkeeper/core/src/TonAPI'; +import { config } from '$config'; +import { i18n } from '@tonkeeper/shared/i18n'; + +export const createTonApiInstance = (isTestnet = false) => { + return new TonAPI({ + baseHeaders: () => ({ + Authorization: `Bearer ${config.get('tonApiV2Key', isTestnet)}`, + }), + baseUrl: () => config.get('tonapiIOEndpoint', isTestnet), + }); +}; + +export const createBatteryApiInstance = (isTestnet = false) => { + return new BatteryAPI({ + baseUrl: () => { + if (isTestnet) { + return config.get('batteryTestnetHost'); + } + + return config.get('batteryHost'); + }, + baseHeaders: { + 'Accept-Language': i18n.locale, + }, + }); +}; + +export const createTronApiInstance = (isTestnet = false) => { + return new TronAPI({ + baseUrl: () => { + if (isTestnet) { + return config.get('tronapiTestnetHost'); + } + + return config.get('tronapiHost'); + }, + }); +}; diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json index df2712410..0e91a7813 100644 --- a/packages/mobile/tsconfig.json +++ b/packages/mobile/tsconfig.json @@ -21,7 +21,8 @@ "$shared": ["shared"], "$assets": ["assets"], "$navigation": ["navigation"], - "$services": ["services"], + "$wallet": ["wallet"], + "$logger": ["logger"], "$translation": ["translation"], "$tonconnect": ["tonconnect"], "$api/*": ["api/*"], @@ -37,13 +38,16 @@ "$shared/*": ["shared/*"], "$assets/*": ["assets/*"], "$navigation/*": ["navigation/*"], - "$services/*": ["services/*"], + "$wallet/*": ["wallet/*"], + "$logger/*": ["logger/*"], "$translation/*": ["translation/"], "$blockchain": ["blockchain/"], "$database": ["database/"], - "$tonconnect/*": ["tonconnect/*"] + "$tonconnect/*": ["tonconnect/*"], + "$config": ["config"], + "$components": ["components"], } }, - "include": ["src"], + "include": ["src", "src/wallet/managers", "src/wallet/Wallet"], "exclude": ["node_modules"] } \ No newline at end of file diff --git a/packages/router/src/SheetsProvider.tsx b/packages/router/src/SheetsProvider.tsx index 99ba25e9c..69c1261f3 100644 --- a/packages/router/src/SheetsProvider.tsx +++ b/packages/router/src/SheetsProvider.tsx @@ -14,8 +14,8 @@ export type SheetParams = Record; export enum SheetActions { REPLACE = 'REPLACE', - ADD = 'ADD' -}; + ADD = 'ADD', +} export type SheetInitialState = 'opened' | 'closed'; @@ -26,39 +26,42 @@ export type SheetStackParams = { component: React.ComponentType; }; -export type SheetStack = SheetStackParams & { +export type SheetStack = SheetStackParams & { id: string; initialState: SheetInitialState; }; export type SheetStackList = SheetStack[]; -export type SheetContextValue = - & BottomSheetRefs - & SheetMeasurements - & { +export type SheetContextValue = BottomSheetRefs & + SheetMeasurements & { + id: string; + initialState: SheetInitialState; + delegateMethods: (methods: SheetMethods) => void; + removeFromStack: () => void; + close: () => void; + onClose?: (() => void) | null; + }; + +export type SheetStackDispatchAction = + | { + type: 'ADD'; id: string; - initialState: SheetInitialState; - delegateMethods: (methods: SheetMethods) => void; - removeFromStack: () => void; - close: () => void; + path: string; + params: SheetParams; + component: React.ComponentType; + initialState?: SheetInitialState; + } + | { + type: 'REMOVE'; + id: string; + } + | { + type: 'REMOVE_ALL'; }; -export type SheetStackDispatchAction = { - type: 'ADD'; - id: string; - path: string; - params: SheetParams; - component: React.ComponentType; - initialState?: SheetInitialState; -} | { - type: 'REMOVE' - id: string; -} | { - type: 'REMOVE_ALL' -}; - -const SheetDispatchContext = React.createContext | null>(null); +const SheetDispatchContext = + React.createContext | null>(null); const SheetContext = React.createContext(null); function SheetReducer(stack: SheetStackList, action: SheetStackDispatchAction) { @@ -66,17 +69,17 @@ function SheetReducer(stack: SheetStackList, action: SheetStackDispatchAction) { case 'ADD': return [ ...stack, - { + { initialState: action.initialState ?? 'opened', path: action.path, params: action.params, component: action.component, id: action.id, - } + }, ]; case 'REMOVE': return stack.filter((item) => item.id !== action.id); - case 'REMOVE_ALL': + case 'REMOVE_ALL': return []; } } @@ -84,9 +87,12 @@ function SheetReducer(stack: SheetStackList, action: SheetStackDispatchAction) { const useSheetRefs = () => { const refs = React.useRef(new Map()); - const setRef = React.useCallback((id: string) => (el: SheetInternalRef) => { - refs.current.set(id, el); - }, []); + const setRef = React.useCallback( + (id: string) => (el: SheetInternalRef) => { + refs.current.set(id, el); + }, + [], + ); const removeRef = React.useCallback((id: string) => { refs.current.delete(id); @@ -95,7 +101,7 @@ const useSheetRefs = () => { const getRef = React.useCallback((id: string) => { return refs.current.get(id); }, []); - + const getLastRef = React.useCallback(() => { return Array.from(refs.current)[refs.current.size - 1]?.[1]; }, []); @@ -106,85 +112,89 @@ const useSheetRefs = () => { setRef, getRef, }; -} +}; type SheetsProviderParams = { component: React.ComponentType; $$action: SheetActions; params: Record; - path: string; + path: string; }; export type SheetsProviderRef = { replaceStack: (stack: SheetStackParams) => void; addStack: (stack: SheetStackParams) => void; -} +}; -export const SheetsProvider = React.memo(React.forwardRef((props, ref) => { - const nav = useNavigation(); - const sheetsRegistry = useSheetRefs(); - const [stack, dispatch] = React.useReducer(SheetReducer, []); - - const removeFromStack = React.useCallback((id: string, noGoBack?: boolean) => { - if (stack.length > 1) { - dispatch({ type: 'REMOVE', id }); - sheetsRegistry.removeRef(id); - } else if (!noGoBack) { - nav.goBack(); - } - }, [stack.length]); +export const SheetsProvider = React.memo( + React.forwardRef((props, ref) => { + const nav = useNavigation(); + const sheetsRegistry = useSheetRefs(); + const [stack, dispatch] = React.useReducer(SheetReducer, []); + const removeFromStack = React.useCallback( + (id: string, noGoBack?: boolean) => { + if (noGoBack) { + dispatch({ type: 'REMOVE', id }); + sheetsRegistry.removeRef(id); + } else { + nav.goBack(); + } + }, + [stack], + ); - const replaceStack = React.useCallback(async (stack: SheetStackParams) => { - const modalId = addStack({ ...stack, initialState: 'closed' }); - const lastSheet = sheetsRegistry.getLastRef(); + const replaceStack = React.useCallback(async (stack: SheetStackParams) => { + const modalId = addStack({ ...stack, initialState: 'closed' }); + const lastSheet = sheetsRegistry.getLastRef(); - if (lastSheet) { - if (!isAndroid) { - Keyboard.dismiss(); + if (lastSheet) { + if (!isAndroid) { + Keyboard.dismiss(); + } + lastSheet.close(() => { + sheetsRegistry.getRef(modalId)?.present(); + }); } - lastSheet.close(() => { - sheetsRegistry.getRef(modalId)?.present(); - }) - } - }, []); - - const addStack = (stack: SheetStackParams) => { - const id = nanoid(); - dispatch({ - initialState: stack.initialState, - component: stack.component, - params: stack.params, - path: stack.path, - type: 'ADD', - id - }); - - return id; - }; + }, []); + + const addStack = (stack: SheetStackParams) => { + const id = nanoid(); + dispatch({ + initialState: stack.initialState, + component: stack.component, + params: stack.params, + path: stack.path, + type: 'ADD', + id, + }); + + return id; + }; - React.useImperativeHandle(ref, () => ({ - replaceStack, - addStack - })); - - return ( - - {stack.map((item) => ( - - ))} - - ); -})); + React.useImperativeHandle(ref, () => ({ + replaceStack, + addStack, + })); + + return ( + + {stack.map((item) => ( + + ))} + + ); + }), +); interface SheetInternalProps { removeFromStack: (id: string, noGoBack?: boolean) => void; - item: SheetStack; -}; + item: SheetStack; +} type SheetInternalRef = { close: (onClosed?: () => void) => void; @@ -194,75 +204,77 @@ type SheetInternalRef = { type SheetMethods = { present: () => void; close: () => void; -} +}; -const SheetInternal = React.forwardRef< - SheetInternalRef, - SheetInternalProps ->((props, ref) => { - const closedSheetCallback = React.useRef<(() => void) | null>(null); - const delegatedMethods = React.useRef(null); - const measurements = useSheetMeasurements(); - - const delegateMethods = (methods: SheetMethods) => { - delegatedMethods.current = methods; - }; +const SheetInternal = React.forwardRef( + (props, ref) => { + const closedSheetCallback = React.useRef<(() => void) | null>(null); + const delegatedMethods = React.useRef(null); + const measurements = useSheetMeasurements(); - const removeFromStack = () => { - if (closedSheetCallback.current) { - closedSheetCallback.current(); - props.removeFromStack(props.item.id, true); - } else { - props.removeFromStack(props.item.id); - } - }; + const delegateMethods = (methods: SheetMethods) => { + delegatedMethods.current = methods; + }; - const close = () => { - if (!isAndroid) { - Keyboard.dismiss(); - } + const removeFromStack = () => { + if (closedSheetCallback.current) { + closedSheetCallback.current(); + props.removeFromStack(props.item.id, true); + } else { + props.removeFromStack(props.item.id); + } + }; - if (delegatedMethods.current) { - delegatedMethods.current.close(); - } else { - removeFromStack(); - } - }; + const close = () => { + if (!isAndroid) { + Keyboard.dismiss(); + } - React.useImperativeHandle(ref, () => ({ - present: () => { - delegatedMethods.current?.present(); - }, - close: (onClosed) => { if (delegatedMethods.current) { - if (onClosed) { - closedSheetCallback.current = onClosed; - } - delegatedMethods.current.close(); + } else { + removeFromStack(); } - }, - })) - - const value: SheetContextValue = { - ...measurements, - initialState: props.item.initialState, - id: props.item.id, - delegateMethods, - removeFromStack, - close, - }; + }; - const SheetComponent = props.item.component; - - return ( - - - - - - ); -}); + React.useImperativeHandle( + ref, + () => ({ + present: () => { + delegatedMethods.current?.present(); + }, + close: (onClosed) => { + if (onClosed) { + closedSheetCallback.current = onClosed; + } + + close(); + }, + }), + [close], + ); + + const value: SheetContextValue = { + ...measurements, + initialState: props.item.initialState, + id: props.item.id, + delegateMethods, + removeFromStack, + close, + onClose: closedSheetCallback.current, + }; + + const SheetComponent = props.item.component; + + return ( + + + + + + ); + }, +); export const useSheetInternal = () => { const sheet = React.useContext(SheetContext); @@ -277,4 +289,4 @@ export const useSheetInternal = () => { export const useCloseModal = () => { const sheet = React.useContext(SheetContext); return sheet?.close; -}; \ No newline at end of file +}; diff --git a/packages/router/src/hooks/useNavigation.ts b/packages/router/src/hooks/useNavigation.ts index 3114dff8b..881b2c241 100644 --- a/packages/router/src/hooks/useNavigation.ts +++ b/packages/router/src/hooks/useNavigation.ts @@ -93,6 +93,10 @@ export const useNavigation = () => { } }; + const pop = (level: number) => { + nav.dispatch(StackActions.pop(level)); + }; + const openModal = (path: string, params?: any) => { const find = sheetRoutes.find((el) => el.path === path); if (find) { @@ -116,6 +120,7 @@ export const useNavigation = () => { setParams, navigate, goBack, + pop, reset, closeModal, push, diff --git a/packages/router/src/imperative.ts b/packages/router/src/imperative.ts index f9b69e818..ac6e8ec79 100644 --- a/packages/router/src/imperative.ts +++ b/packages/router/src/imperative.ts @@ -64,7 +64,7 @@ export const navigation = { reset, }; -export const useParams = (): Partial => { +export const useParams = (): T => { const route = useRoute(); - return route.params ?? {}; + return (route.params as T) ?? ({} as T); }; diff --git a/packages/shared/Address.ts b/packages/shared/Address.ts index 7d30f7af1..0eace9fd9 100644 --- a/packages/shared/Address.ts +++ b/packages/shared/Address.ts @@ -1,7 +1,7 @@ -import { WalletNetwork } from '@tonkeeper/core/src/Wallet'; import { AddressFormatter } from '@tonkeeper/core/src/formatters/Address'; -import { tk } from './tonkeeper'; +import { tk } from '@tonkeeper/mobile/src/wallet'; +import { WalletNetwork } from '@tonkeeper/mobile/src/wallet/WalletTypes'; export const Address = new AddressFormatter({ - testOnly: () => tk.wallet?.identity.network === WalletNetwork.testnet, + testOnly: () => tk.wallet?.config.network === WalletNetwork.testnet, }); diff --git a/packages/shared/components/ActivityList/ActionListItem.tsx b/packages/shared/components/ActivityList/ActionListItem.tsx index 2e4c3ccd5..de76b90d9 100644 --- a/packages/shared/components/ActivityList/ActionListItem.tsx +++ b/packages/shared/components/ActivityList/ActionListItem.tsx @@ -18,17 +18,17 @@ import { memo, useCallback, useMemo } from 'react'; import { ImageRequireSource } from 'react-native'; import { Address } from '../../Address'; import { t } from '../../i18n'; + +import { useHideableFormatter } from '@tonkeeper/mobile/src/core/HideableAmount/useHideableFormatter'; +import { useFlags } from '@tonkeeper/mobile/src/utils/flags'; +import { config } from '@tonkeeper/mobile/src/config'; +import { AmountFormatter } from '@tonkeeper/core'; import { - ActionSource, ActionType, - AmountFormatter, AnyActionItem, isJettonTransferAction, -} from '@tonkeeper/core'; - -import { useHideableFormatter } from '@tonkeeper/mobile/src/core/HideableAmount/useHideableFormatter'; -import { useFlags } from '@tonkeeper/mobile/src/utils/flags'; -import { config } from '../../config'; + ActionSource, +} from '@tonkeeper/mobile/src/wallet/models/ActivityModel'; export interface ActionListItemProps { onPress?: () => void; @@ -46,6 +46,7 @@ export interface ActionListItemProps((props) => { const { action } = props; @@ -48,7 +52,10 @@ export const ActionListItemByType = memo((props) => { case ActionType.NftItemTransfer: return ( - + {!!payload.comment && } {!!payload.encrypted_comment && ( ((props) => { case ActionType.NftPurchase: return ( - + ); case ActionType.SmartContractExec: diff --git a/packages/shared/components/ActivityList/ActivityList.tsx b/packages/shared/components/ActivityList/ActivityList.tsx index b13e5e2ad..60b928a4d 100644 --- a/packages/shared/components/ActivityList/ActivityList.tsx +++ b/packages/shared/components/ActivityList/ActivityList.tsx @@ -1,6 +1,5 @@ import { DefaultSectionT, SectionListData, StyleSheet, View } from 'react-native'; import { formatTransactionsGroupDate } from '../../utils/date'; -import { ActivitySection } from '@tonkeeper/core'; import { renderActionItem } from './ActionListItemByType'; import { memo, useMemo } from 'react'; import { @@ -13,6 +12,7 @@ import { Button, copyText, } from '@tonkeeper/uikit'; +import { ActivitySection } from '@tonkeeper/mobile/src/wallet/models/ActivityModel'; type ListComponentType = React.ComponentType | React.ReactElement | null | undefined; @@ -108,7 +108,7 @@ export const ActivityList = memo((props) => { onEndReachedThreshold={0.02} updateCellsBatchingPeriod={60} maxToRenderPerBatch={10} - initialNumToRender={20} + initialNumToRender={1} stickySectionHeadersEnabled={false} windowSize={16} sections={sections} diff --git a/packages/shared/components/ActivityList/findSenderAccount.ts b/packages/shared/components/ActivityList/findSenderAccount.ts index addb34904..f5bdc939d 100644 --- a/packages/shared/components/ActivityList/findSenderAccount.ts +++ b/packages/shared/components/ActivityList/findSenderAccount.ts @@ -1,8 +1,9 @@ -import { AnyActionItem } from '@tonkeeper/core'; +import { AnyActionItem } from '@tonkeeper/mobile/src/wallet/models/ActivityModel'; export function findSenderAccount(action: AnyActionItem) { if (action.payload && ('sender' in action.payload || 'recipient' in action.payload)) { - const senderAccount = action.destination === 'in' ? action.payload?.sender : action.payload?.recipient; + const senderAccount = + action.destination === 'in' ? action.payload?.sender : action.payload?.recipient; if (senderAccount) { return senderAccount; } diff --git a/packages/shared/components/ActivityList/items/JettonSwapActionListItem.tsx b/packages/shared/components/ActivityList/items/JettonSwapActionListItem.tsx index d2cf7486c..6e8eed3df 100644 --- a/packages/shared/components/ActivityList/items/JettonSwapActionListItem.tsx +++ b/packages/shared/components/ActivityList/items/JettonSwapActionListItem.tsx @@ -1,14 +1,13 @@ import { ActionListItem, ActionListItemProps } from '../ActionListItem'; -import { Address, ActionType, AmountFormatter } from '@tonkeeper/core'; +import { Address, AmountFormatter } from '@tonkeeper/core'; import { ActionStatusEnum } from '@tonkeeper/core/src/TonAPI'; import { formatTransactionTime } from '../../../utils/date'; import { View, StyleSheet } from 'react-native'; import { Text } from '@tonkeeper/uikit'; import { memo, useMemo } from 'react'; import { t } from '../../../i18n'; - import { useHideableFormatter } from '@tonkeeper/mobile/src/core/HideableAmount/useHideableFormatter'; -import { getFlag } from '@tonkeeper/mobile/src/utils/flags'; +import { ActionType } from '@tonkeeper/mobile/src/wallet/models/ActivityModel'; type JettonSwapActionListItemProps = ActionListItemProps; diff --git a/packages/shared/components/ActivityList/items/SubscribeActionListItem.tsx b/packages/shared/components/ActivityList/items/SubscribeActionListItem.tsx index 1895b7881..8106b3bbc 100644 --- a/packages/shared/components/ActivityList/items/SubscribeActionListItem.tsx +++ b/packages/shared/components/ActivityList/items/SubscribeActionListItem.tsx @@ -1,9 +1,10 @@ import { ActionListItem, ActionListItemProps } from '../ActionListItem'; import { useSubscription } from '../../../query/hooks/useSubscription'; -import { ActionType } from '@tonkeeper/core'; + import { StyleSheet } from 'react-native'; import { t } from '../../../i18n'; import { memo } from 'react'; +import { ActionType } from '@tonkeeper/mobile/src/wallet/models/ActivityModel'; type SubscribeActionListItemProps = ActionListItemProps; diff --git a/packages/shared/components/ActivityList/items/UnSubscribeActionListItem.tsx b/packages/shared/components/ActivityList/items/UnSubscribeActionListItem.tsx index 931f603cd..a5d873d91 100644 --- a/packages/shared/components/ActivityList/items/UnSubscribeActionListItem.tsx +++ b/packages/shared/components/ActivityList/items/UnSubscribeActionListItem.tsx @@ -1,8 +1,9 @@ import { ActionListItem, ActionListItemProps } from '../ActionListItem'; import { useSubscription } from '../../../query/hooks/useSubscription'; -import { ActionType } from '@tonkeeper/core'; + import { t } from '../../../i18n'; import { memo } from 'react'; +import { ActionType } from '@tonkeeper/mobile/src/wallet/models/ActivityModel'; type UnSubscribeActionListItemProps = ActionListItemProps; diff --git a/packages/shared/components/BatteryIcon/BatteryIcon.tsx b/packages/shared/components/BatteryIcon/BatteryIcon.tsx index e11a77423..052931752 100644 --- a/packages/shared/components/BatteryIcon/BatteryIcon.tsx +++ b/packages/shared/components/BatteryIcon/BatteryIcon.tsx @@ -3,7 +3,7 @@ import { useBatteryBalance } from '../../query/hooks/useBatteryBalance'; import { Icon, IconNames, TouchableOpacity } from '@tonkeeper/uikit'; import { BatteryState, getBatteryState } from '../../utils/battery'; import { openRefillBatteryModal } from '../../modals/RefillBatteryModal'; -import { config } from '../../config'; +import { config } from '@tonkeeper/mobile/src/config'; const iconNames: { [key: string]: IconNames } = { [BatteryState.Empty]: 'ic-empty-battery-28', diff --git a/packages/shared/components/EncryptedComment/EncryptedComment.tsx b/packages/shared/components/EncryptedComment/EncryptedComment.tsx index 132bf0381..c640c3a7d 100644 --- a/packages/shared/components/EncryptedComment/EncryptedComment.tsx +++ b/packages/shared/components/EncryptedComment/EncryptedComment.tsx @@ -17,6 +17,7 @@ import { import { SpoilerViewMock } from './components/SpoilerViewMock'; import { useCopyText } from '@tonkeeper/mobile/src/hooks/useCopyText'; import { openEncryptedCommentModalIfNeeded } from '../../modals/EncryptedCommentModal'; +import { tk } from '@tonkeeper/mobile/src/wallet'; export enum EncryptedCommentLayout { LIST_ITEM, @@ -86,6 +87,9 @@ const EncryptedCommentComponent: React.FC = (props) => { ); const handleDecryptComment = useCallback(() => { + if (tk.wallet.isWatchOnly) { + return; + } openEncryptedCommentModalIfNeeded(() => decryptComment(props.actionId, props.encryptedComment, props.sender.address), ); diff --git a/packages/shared/components/InputNumberPrefix.tsx b/packages/shared/components/InputNumberPrefix.tsx index 6043f9c23..8ea12d591 100644 --- a/packages/shared/components/InputNumberPrefix.tsx +++ b/packages/shared/components/InputNumberPrefix.tsx @@ -1,17 +1,19 @@ -import { Text, isAndroid } from '@tonkeeper/uikit'; +import { Steezy, Text, View, isAndroid } from '@tonkeeper/uikit'; import { StyleSheet } from 'react-native'; export const InputNumberPrefix = ({ index }: { index: number }) => ( - - {index + 1}: - + + + {index + 1}: + + ); -const styles = StyleSheet.create({ - inputNumberText: { - textAlignVertical: isAndroid ? 'top' : 'auto', - textAlign: 'right', - width: 28, - left: 10, +const styles = Steezy.create({ + container: { + flex: 1, + width: 50, + paddingRight: 12, + justifyContent: 'center', }, }); diff --git a/packages/shared/components/PasscodeKeyboad.tsx b/packages/shared/components/PasscodeKeyboad.tsx deleted file mode 100644 index 55e68626e..000000000 --- a/packages/shared/components/PasscodeKeyboad.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { StyleSheet, TouchableOpacity, View } from 'react-native'; -import { Haptics, Icon, Text, useTheme } from '@tonkeeper/uikit'; -import Animated, { - Easing, - interpolate, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; - -// TODO: move to other file -import * as LocalAuthentication from 'expo-local-authentication'; -export function detectBiometryType(types: LocalAuthentication.AuthenticationType[]) { - let found = false; - for (let type of types) { - if ( - [ - LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION, - LocalAuthentication.AuthenticationType.FINGERPRINT, - ].indexOf(type) > -1 - ) { - found = true; - break; - } - } - - if (found) { - return types.indexOf(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION) > -1 - ? LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION - : LocalAuthentication.AuthenticationType.FINGERPRINT; - } else { - return null; - } -} - -interface KeyboardKeyProps { - onPress?: () => void; - disabled?: boolean; - children?: React.ReactNode; -} - -const KeyboardKey = memo((props) => { - const { onPress, children, disabled } = props; - const animValue = useSharedValue(0); - const theme = useTheme(); - - const handlePressIn = useCallback(() => { - animValue.value = withTiming(1, { - duration: 50, - easing: Easing.linear, - }); - }, []); - - const handlePressOut = useCallback(() => { - animValue.value = withTiming(0, { - duration: 50, - easing: Easing.linear, - }); - }, []); - - const style = useAnimatedStyle(() => ({ - opacity: animValue.value, - transform: [ - { - scale: interpolate(animValue.value, [0, 1], [0.75, 1]), - }, - ], - })); - - const bgHighlightStyle = { - backgroundColor: theme.backgroundContent, - }; - - return ( - - - {children} - - ); -}); - -interface PasscodeKeyboardProps { - value?: string; - onChange?: (value: string) => void; - disabled?: boolean; - biometryEnabled?: boolean; - onBiometryPress?: () => void; -} - -export const PasscodeKeyboard = memo((props) => { - const { - value, - onChange, - disabled = false, - biometryEnabled = false, - onBiometryPress, - } = props; - const [biometryType, setBiometryType] = useState(-1); - - useEffect(() => { - if (biometryEnabled) { - Promise.all([ - (async () => false)(), // MainDB.isBiometryEnabled(), - LocalAuthentication.supportedAuthenticationTypesAsync(), - ]).then(([isEnabled, types]) => { - if (isEnabled) { - const type = detectBiometryType(types); - if (type) { - setBiometryType(type); - } - } - }); - } else { - setBiometryType(-1); - } - }, [biometryEnabled]); - - const handlePress = useCallback( - (num: number) => () => { - Haptics.selection(); - onChange?.(`${value}${num}`); - }, - [onChange, value], - ); - - const handleBackspace = useCallback(() => { - Haptics.selection(); - onChange?.(value?.substring(0, value.length - 1) ?? ''); - }, [onChange, value]); - - const nums = useMemo(() => { - let result: Array[] = []; - for (let i = 0; i < 3; i++) { - let line: number[] = []; - for (let j = 0; j < 3; j++) { - const num = j + 1 + i * 3; - line.push(num); - } - - result.push(line); - } - - return result; - }, []); - - const isFingerprint = - biometryType === LocalAuthentication.AuthenticationType.FINGERPRINT; - const isFace = LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION; - - return ( - - {nums.map((line, i) => ( - - {line.map((num) => ( - - {num} - - ))} - - ))} - - {onBiometryPress ? ( - - {isFingerprint ? ( - - ) : isFace ? ( - - ) : null} - - ) : ( - - )} - - 0 - - {value && value.length > 0 ? ( - - - - ) : ( - - )} - - - ); -}); - -const styles = StyleSheet.create({ - container: { - padding: 16, - }, - line: { - flexDirection: 'row', - justifyContent: 'space-around', - }, - keyCircle: { - width: 72, - height: 72, - borderRadius: 72 / 2, - position: 'absolute', - top: 0, - left: 0, - zIndex: 1, - }, - keyContent: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - zIndex: 2, - }, - key: { - width: 72, - height: 72, - position: 'relative', - }, -}); diff --git a/packages/shared/components/ReceiveTokenContent.tsx b/packages/shared/components/ReceiveTokenContent.tsx index 2198cb0d7..0d28fb80c 100644 --- a/packages/shared/components/ReceiveTokenContent.tsx +++ b/packages/shared/components/ReceiveTokenContent.tsx @@ -14,6 +14,8 @@ import { Text, Icon, } from '@tonkeeper/uikit'; +import { Tag } from '@tonkeeper/mobile/src/uikit'; +import { tk } from '@tonkeeper/mobile/src/wallet'; interface ReceiveTokenContentProps { address: string; @@ -23,6 +25,7 @@ interface ReceiveTokenContentProps { title: string; description: string; qrCodeScale: number; + isWatchOnly?: boolean; } export const ReceiveTokenContent = memo((props) => { @@ -34,6 +37,7 @@ export const ReceiveTokenContent = memo((props) => { description, title, qrCodeScale, + isWatchOnly, } = props; const [render, setRender] = useState(renderDelay > 0 ? false : true); @@ -78,11 +82,19 @@ export const ReceiveTokenContent = memo((props) => { ) : ( )} - - - {address} - - + + + + {address} + + + {tk.wallet.isWatchOnly ? ( + <> + + {t('watch_only')} + + ) : null} +