diff --git a/packages/@core-js/scripts/generate-battery.js b/packages/@core-js/scripts/generate-battery.js new file mode 100644 index 000000000..b998dd0a2 --- /dev/null +++ b/packages/@core-js/scripts/generate-battery.js @@ -0,0 +1,14 @@ +const { generateApi } = require('swagger-typescript-api'); +const path = require('path'); + +generateApi({ + url: 'https://raw.githubusercontent.com/tonkeeper/custodial-battery/master/api/battery-api.yml', + output: path.resolve(__dirname, '../src/BatteryAPI'), + name: 'BatteryGenerated', + extractRequestParams: true, + apiClassName: 'BatteryGenerated', + moduleNameIndex: 1, + extractEnums: true, + singleHttpClient: true, + unwrapResponseData: true, +}); diff --git a/packages/@core-js/src/BatteryAPI/BatteryAPI.ts b/packages/@core-js/src/BatteryAPI/BatteryAPI.ts new file mode 100644 index 000000000..34e1382a6 --- /dev/null +++ b/packages/@core-js/src/BatteryAPI/BatteryAPI.ts @@ -0,0 +1,8 @@ +import { HttpClient, HttpClientOptions } from '../TonAPI/HttpClient'; +import { BatteryGenerated } from './BatteryGenerated'; + +export class BatteryAPI extends BatteryGenerated { + constructor(opts: HttpClientOptions) { + super(new (HttpClient as any)(opts)); + } +} diff --git a/packages/@core-js/src/BatteryAPI/BatteryGenerated.ts b/packages/@core-js/src/BatteryAPI/BatteryGenerated.ts new file mode 100644 index 000000000..3bc6985c2 --- /dev/null +++ b/packages/@core-js/src/BatteryAPI/BatteryGenerated.ts @@ -0,0 +1,595 @@ +/* eslint-disable */ +/* tslint:disable */ +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface Error { + /** @example "error description" */ + error: string; +} + +export interface Config { + /** + * when building a message to transfer an NFT or Jetton, use this address to send excess funds back to Battery Service. + * @example "0:da6b1b6663a0e4d18cc8574ccd9db5296e367dd9324706f3bbd9eb1cd2caf0bf" + */ + excess_account: string; +} + +export interface Balance { + /** @example "10.250" */ + balance: string; +} + +export interface Purchases { + /** @example 1 */ + total_purchases: number; + /** + * if set, then there are more purchases to be loaded. Use this value as offset parameter in the next request. + * @example 10 + */ + next_offset?: number; + purchases: { + /** @example 2 */ + id: number; + /** @example "android" */ + type: PurchasesTypeEnum; + /** @example "10.250" */ + value: string; + /** @example "2006-01-02T15:04:05Z07:00" */ + datetime: string; + }[]; +} + +export interface AndroidBatteryPurchaseStatus { + purchases: { + /** @example "1000000790000000" */ + product_id: string; + /** @example "1000000790000000" */ + token: string; + /** @example true */ + success: boolean; + error?: { + /** @example "Temporary error. Try again later." */ + msg: string; + /** @example "invalid-product-id" */ + code: AndroidBatteryPurchaseStatusCodeEnum; + }; + }[]; +} + +export interface IOSBatteryPurchaseStatus { + transactions: { + /** @example "1000000790000000" */ + transaction_id: string; + /** @example true */ + success: boolean; + error?: { + /** @example "Temporary error. Try again later." */ + msg: string; + /** @example "invalid-bundle-id" */ + code: IOsBatteryPurchaseStatusCodeEnum; + }; + }[]; +} + +export interface PromoCodeBatteryPurchaseStatus { + /** @example "10.250" */ + balance_change: string; + /** @example true */ + success: boolean; + error?: { + /** @example "Temporary error. Try again later." */ + msg: string; + /** @example "promo-code-is-already-used" */ + code: PromoCodeBatteryPurchaseStatusCodeEnum; + }; +} + +export interface Transactions { + /** @example 1 */ + total_transactions: number; + /** + * if set, then there are more transactions to be loaded. Use this value as offset parameter in the next request. + * @example 10 + */ + next_offset?: number; + transactions: { + id: string; + /** + * represents the amount of money paid by the user for this transaction. + * @example "10.250" + */ + paid_amount: string; + /** @example "10.250" */ + status: TransactionsStatusEnum; + /** @example "2006-01-02T15:04:05Z07:00" */ + created_at: string; + }[]; +} + +/** @example "android" */ +export enum PurchasesTypeEnum { + RegularPurchase = 'regular-purchase', + PromoCode = 'promo-code', +} + +/** @example "invalid-product-id" */ +export enum AndroidBatteryPurchaseStatusCodeEnum { + InvalidProductId = 'invalid-product-id', + UserNotFound = 'user-not-found', + PurchaseIsAlreadyUsed = 'purchase-is-already-used', + TemporaryError = 'temporary-error', + Unknown = 'unknown', +} + +/** @example "invalid-bundle-id" */ +export enum IOsBatteryPurchaseStatusCodeEnum { + InvalidBundleId = 'invalid-bundle-id', + InvalidProductId = 'invalid-product-id', + UserNotFound = 'user-not-found', + PurchaseIsAlreadyUsed = 'purchase-is-already-used', + TemporaryError = 'temporary-error', + Unknown = 'unknown', +} + +/** @example "promo-code-is-already-used" */ +export enum PromoCodeBatteryPurchaseStatusCodeEnum { + PromoCodeIsAlreadyUsed = 'promo-code-is-already-used', + PromoCodeNotFound = 'promo-code-not-found', + TemporaryError = 'temporary-error', +} + +/** @example "10.250" */ +export enum TransactionsStatusEnum { + Pending = 'pending', + Completed = 'completed', + Failed = 'failed', +} + +export interface GetPurchasesParams { + /** + * @max 1000 + * @default 1000 + */ + limit?: number; + /** @default 0 */ + offset?: number; +} + +export interface GetTransactionsParams { + /** + * @max 1000 + * @default 1000 + */ + limit?: number; + /** @default 0 */ + offset?: number; +} + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit; + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = 'application/json', + FormData = 'multipart/form-data', + UrlEncoded = 'application/x-www-form-urlencoded', + Text = 'text/plain', +} + +export class HttpClient { + public baseUrl: string = 'https://battery.tonkeeper.com'; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig['securityWorker']; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: 'same-origin', + headers: {}, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return `${encodedKey}=${encodeURIComponent( + typeof value === 'number' ? value : `${value}`, + )}`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join('&'); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter((key) => 'undefined' !== typeof query[key]); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join('&'); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? `?${queryString}` : ''; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === 'object' || typeof input === 'string') + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== 'string' ? JSON.stringify(input) : input, + [ContentType.FormData]: (input: any) => + Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === 'object' && property !== null + ? JSON.stringify(property) + : `${property}`, + ); + return formData; + }, new FormData()), + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise => { + const secureParams = + ((typeof secure === 'boolean' ? secure : this.baseApiParams.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + `${baseUrl || this.baseUrl || ''}${path}${queryString ? `?${queryString}` : ''}`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData ? { 'Content-Type': type } : {}), + }, + signal: + (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || + null, + body: + typeof body === 'undefined' || body === null ? null : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const data = !responseFormat + ? r + : await response[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data.data; + }); + }; +} + +/** + * @title Custodial-Battery REST API. + * @version 0.0.1 + * @baseUrl https://battery.tonkeeper.com + * @contact Support + * + * REST API for Custodial Battery which provides gas to different networks to help execute transactions. + */ +export class BatteryGenerated { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description This method returns information about the battery service. + * + * @name GetConfig + * @request GET:/config + */ + getConfig = (params: RequestParams = {}) => + this.http.request({ + path: `/config`, + method: 'GET', + format: 'json', + ...params, + }); + + /** + * @description This method returns information about a battery + * + * @name GetBalance + * @request GET:/balance + */ + getBalance = (params: RequestParams = {}) => + this.http.request({ + path: `/balance`, + method: 'GET', + format: 'json', + ...params, + }); + + /** + * @description Send message to blockchain + * + * @name SendMessage + * @request POST:/message + */ + sendMessage = ( + data: { + /** @example "te6ccgECBQEAARUAAkWIAWTtae+KgtbrX26Bep8JSq8lFLfGOoyGR/xwdjfvpvEaHg" */ + boc: string; + }, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/message`, + method: 'POST', + body: data, + ...params, + }); + + /** + * @description This method returns a list of purchases made by a specific user. + * + * @name GetPurchases + * @request GET:/purchases + */ + getPurchases = (query: GetPurchasesParams, params: RequestParams = {}) => + this.http.request({ + path: `/purchases`, + method: 'GET', + query: query, + format: 'json', + ...params, + }); + + /** + * @description This method returns a list of transactions made by a specific user. + * + * @name GetTransactions + * @request GET:/transactions + */ + getTransactions = (query: GetTransactionsParams, params: RequestParams = {}) => + this.http.request({ + path: `/transactions`, + method: 'GET', + query: query, + format: 'json', + ...params, + }); + + emulate = { + /** + * @description Emulate sending message to blockchain + * + * @tags Emulation + * @name EmulateMessageToWallet + * @request POST:/wallet/emulate + */ + emulateMessageToWallet: ( + data: { + /** @example "te6ccgECBQEAARUAAkWIAWTtae+KgtbrX26Bep8JSq8lFLfGOoyGR/xwdjfvpvEaHg" */ + boc: string; + }, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/wallet/emulate`, + method: 'POST', + body: data, + format: 'json', + ...params, + }), + }; + android = { + /** + * @description verify an in-app purchase + * + * @name AndroidBatteryPurchase + * @request POST:/purchase-battery/android + */ + androidBatteryPurchase: ( + data: { + purchases: { + /** @example "0:2cf3b5b8c891e517c9addbda1c0386a09ccacbb0e3faf630b51cfc8152325acb" */ + token: string; + /** @example "0:2cf3b5b8c891e517c9addbda1c0386a09ccacbb0e3faf630b51cfc8152325acb" */ + product_id: string; + }[]; + }, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/purchase-battery/android`, + method: 'POST', + body: data, + format: 'json', + ...params, + }), + }; + ios = { + /** + * @description verify an in-app purchase + * + * @name IosBatteryPurchase + * @request POST:/purchase-battery/ios + */ + iosBatteryPurchase: ( + data: { + transactions: { + id: string; + }[]; + }, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/purchase-battery/ios`, + method: 'POST', + body: data, + format: 'json', + ...params, + }), + }; + promoCode = { + /** + * @description charge battery with promo code + * + * @name PromoCodeBatteryPurchase + * @request POST:/purchase-battery/promo-code + */ + promoCodeBatteryPurchase: ( + data: { + /** @example "1234567890" */ + promo_code: string; + }, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/purchase-battery/promo-code`, + method: 'POST', + body: data, + format: 'json', + ...params, + }), + }; +} diff --git a/packages/@core-js/src/BatteryAPI/index.ts b/packages/@core-js/src/BatteryAPI/index.ts new file mode 100644 index 000000000..ba0745e12 --- /dev/null +++ b/packages/@core-js/src/BatteryAPI/index.ts @@ -0,0 +1,2 @@ +export { BatteryAPI } from './BatteryAPI'; +export * from './BatteryGenerated'; diff --git a/packages/@core-js/src/TonAPI/HttpClient.ts b/packages/@core-js/src/TonAPI/HttpClient.ts index 9b920b60b..46897857d 100644 --- a/packages/@core-js/src/TonAPI/HttpClient.ts +++ b/packages/@core-js/src/TonAPI/HttpClient.ts @@ -134,6 +134,7 @@ export class HttpClient { type, cancelToken, method, + headers, }: FullRequestParams): Promise => { const queryString = query && this.toQueryString(query); const payloadFormatter = this.contentFormatters[type || ContentType.Json]; @@ -154,6 +155,7 @@ export class HttpClient { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, + ...(headers ?? {}), }, signal: cancelToken ? this.createAbortSignal(cancelToken) : null, body: diff --git a/packages/@core-js/src/Tonkeeper.ts b/packages/@core-js/src/Tonkeeper.ts index 9e70e5889..96651fab5 100644 --- a/packages/@core-js/src/Tonkeeper.ts +++ b/packages/@core-js/src/Tonkeeper.ts @@ -7,6 +7,13 @@ 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; @@ -19,6 +26,7 @@ type TonkeeperOptions = { tronapi: TronAPI; storage: Storage; tonapi: TonAPI; + batteryapi: BatteryAPI; vault: Vault; }; @@ -44,6 +52,7 @@ export class Tonkeeper { private queryClient: QueryClient; private tronapi: TronAPI; private tonapi: TonAPI; + private batteryapi: BatteryAPI; private vault: Vault; constructor(options: TonkeeperOptions) { @@ -51,6 +60,7 @@ export class Tonkeeper { this.storage = options.storage; this.tronapi = options.tronapi; this.tonapi = options.tonapi; + this.batteryapi = options.batteryapi; this.vault = options.vault; this.sse = options.sse; @@ -63,6 +73,8 @@ export class Tonkeeper { address: string, isTestnet: boolean, tronAddress: string, + tonProof: string, + walletStateInit: string, // TODO: remove after transition to UQ address format bounceable = true, ) { @@ -74,6 +86,7 @@ export class Tonkeeper { this.queryClient, this.tonapi, this.tronapi, + this.batteryapi, this.vault, this.sse, this.storage, @@ -82,6 +95,7 @@ export class Tonkeeper { tronAddress: tronAddress, address: address, bounceable, + tonProof, }, ); @@ -109,18 +123,20 @@ export class Tonkeeper { // } } - public tronStrorageKey = 'temp-tron-address'; + public tronStorageKey = 'temp-tron-address'; + public tonProofStorageKey = 'temp-ton-proof'; // TODO: rewrite it with multi-accounts public async load() { try { - const tronAddress = await this.storage.getItem(this.tronStrorageKey); - if (tronAddress) { - return { tronAddress: JSON.parse(tronAddress) }; - } - return { tronAddress: null }; + 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 }; + return { tronAddress: null, tonProof: null }; } } @@ -135,7 +151,7 @@ export class Tonkeeper { owner: ownerAddress, }; - await this.storage.setItem(this.tronStrorageKey, JSON.stringify(tronAddress)); + await this.storage.setItem(this.tronStorageKey, JSON.stringify(tronAddress)); return tronAddress; } catch (err) { @@ -143,11 +159,37 @@ export class Tonkeeper { } } + public async obtainProofToken(keyPair: nacl.SignKeyPair) { + const contract = ContractService.getWalletContract( + WalletVersion.v4R2, + Buffer.from(keyPair.publicKey), + ); + 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(); @@ -158,6 +200,7 @@ export class Tonkeeper { this.wallet.jettonActivityList.rehydrate(); this.wallet.tonActivityList.rehydrate(); this.wallet.activityList.rehydrate(); + this.wallet.battery.rehydrate(); } public async lock() { @@ -200,6 +243,7 @@ export class Tonkeeper { } public destroy() { + batteryState.clear(); this.wallet?.destroy(); this.queryClient.clear(); this.wallet = null!; diff --git a/packages/@core-js/src/Wallet.ts b/packages/@core-js/src/Wallet.ts index 93ec0c85a..d0fd994e2 100644 --- a/packages/@core-js/src/Wallet.ts +++ b/packages/@core-js/src/Wallet.ts @@ -14,6 +14,8 @@ 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 { @@ -27,9 +29,11 @@ export enum WalletKind { WatchOnly = 'WatchOnly', } -type WalletIdentity = { +export type WalletIdentity = { network: WalletNetwork; kind: WalletKind; + stateInit: string; + tonProof: string; // id: string; }; @@ -73,6 +77,7 @@ export type WalletContext = { sse: ServerSentEvents; tonapi: TonAPI; tronapi: TronAPI; + batteryapi: BatteryAPI; }; export class Wallet { @@ -83,6 +88,7 @@ export class Wallet { public subscriptions: SubscriptionsManager; public balances: BalancesManager; + public battery: BatteryManager; public nfts: NftsManager; @@ -99,6 +105,7 @@ export class Wallet { private queryClient: QueryClient, private tonapi: TonAPI, private tronapi: TronAPI, + private batteryapi: BatteryAPI, private vault: Vault, private sse: ServerSentEvents, private storage: IStorage, @@ -107,6 +114,8 @@ export class Wallet { this.identity = { kind: WalletKind.Regular, network: walletInfo.network, + stateInit: walletInfo.stateInit, + tonProof: walletInfo.tonProof, }; const tonAddresses = Address.parse(walletInfo.address, { @@ -126,6 +135,7 @@ export class Wallet { address: this.address, tronapi: this.tronapi, tonapi: this.tonapi, + batteryapi: this.batteryapi, sse: this.sse, }; @@ -146,6 +156,7 @@ export class Wallet { 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(); } @@ -171,6 +182,10 @@ export class Wallet { 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, diff --git a/packages/@core-js/src/managers/BatteryManager.ts b/packages/@core-js/src/managers/BatteryManager.ts new file mode 100644 index 000000000..2f8b9f8c8 --- /dev/null +++ b/packages/@core-js/src/managers/BatteryManager.ts @@ -0,0 +1,158 @@ +import { WalletContext, WalletIdentity } from '../Wallet'; +import { MessageConsequences } from '../TonAPI'; +import { Storage } from '../declarations/Storage'; +import { State } from '../utils/State'; + +export interface BatteryState { + isLoading: boolean; + balance?: string; +} + +export const batteryState = new State({ + isLoading: false, + balance: undefined, +}); + +export class BatteryManager { + public state = batteryState; + + constructor( + private ctx: WalletContext, + private identity: WalletIdentity, + private storage: Storage, + ) { + this.state.persist({ + partialize: ({ balance }) => ({ balance }), + storage: this.storage, + key: 'battery', + }); + } + + public async fetchBalance() { + try { + this.state.set({ isLoading: true }); + const data = await this.ctx.batteryapi.getBalance({ + headers: { + 'X-TonConnect-Auth': this.identity.tonProof, + }, + }); + this.state.set({ isLoading: false, balance: data.balance }); + } catch (err) { + return null; + } + } + + public async getExcessesAccount() { + try { + const data = await this.ctx.batteryapi.getConfig({ + headers: { + 'X-TonConnect-Auth': this.identity.tonProof, + }, + }); + + return data.excess_account; + } catch (err) { + return null; + } + } + + public async applyPromo(promoCode: string) { + try { + const data = await this.ctx.batteryapi.promoCode.promoCodeBatteryPurchase( + { promo_code: promoCode }, + { + headers: { + 'X-TonConnect-Auth': this.identity.tonProof, + }, + }, + ); + + if (data.success) { + this.fetchBalance(); + } + + return data; + } catch (err) { + return { success: false, error: { msg: 'Unexpected error' } }; + } + } + + public async makeIosPurchase(transactions: { id: string }[]) { + try { + const data = await this.ctx.batteryapi.ios.iosBatteryPurchase( + { transactions: transactions }, + { + headers: { + 'X-TonConnect-Auth': this.identity.tonProof, + }, + }, + ); + + await this.fetchBalance(); + + return data.transactions; + } catch (err) { + console.log('[ios battery in-app purchase]', err); + } + } + + public async makeAndroidPurchase(purchases: { token: string; product_id: string }[]) { + try { + const data = await this.ctx.batteryapi.android.androidBatteryPurchase( + { purchases }, + { + headers: { + 'X-TonConnect-Auth': this.identity.tonProof, + }, + }, + ); + + await this.fetchBalance(); + + return data.purchases; + } catch (err) { + console.log('[android battery in-app purchase]', err); + } + } + + public async sendMessage(boc: string) { + try { + await this.ctx.batteryapi.sendMessage( + { boc }, + { + headers: { + 'X-TonConnect-Auth': this.identity.tonProof, + }, + format: 'text', + }, + ); + + await this.fetchBalance(); + } catch (err) { + throw new Error(err); + } + } + + public async emulate(boc: string): Promise { + try { + return await this.ctx.batteryapi.emulate.emulateMessageToWallet( + { boc }, + { + headers: { + 'X-TonConnect-Auth': this.identity.tonProof, + }, + }, + ); + } catch (err) { + throw new Error(err); + } + } + + public async rehydrate() { + return this.state.rehydrate(); + } + + public async clear() { + return this.state.clear(); + } +} diff --git a/packages/@core-js/src/utils/tonProof.ts b/packages/@core-js/src/utils/tonProof.ts new file mode 100644 index 000000000..02869087d --- /dev/null +++ b/packages/@core-js/src/utils/tonProof.ts @@ -0,0 +1,94 @@ +import { Int64LE } from 'int64-buffer'; +import { Buffer } from 'buffer'; +import nacl from 'tweetnacl'; +import naclUtils from 'tweetnacl-util'; +const { createHash } = require('react-native-crypto'); +import { Address } from '../formatters/Address'; + +export interface TonProofArgs { + address: string; + secretKey: Uint8Array; + walletStateInit: string; + domain: string; + payload: string; +} + +export async function createTonProof({ + address: _addr, + payload, + secretKey, + walletStateInit, + domain, +}: TonProofArgs) { + try { + const address = Address.parse(_addr).toRaw(); + const timestamp = Math.floor(Date.now() / 1000); + const timestampBuffer = new Int64LE(timestamp).toBuffer(); + + const domainBuffer = Buffer.from(domain); + const domainLengthBuffer = Buffer.allocUnsafe(4); + domainLengthBuffer.writeInt32LE(domainBuffer.byteLength); + + const [workchain, addrHash] = address.split(':'); + + const addressWorkchainBuffer = Buffer.allocUnsafe(4); + addressWorkchainBuffer.writeInt32BE(Number(workchain)); + + const addressBuffer = Buffer.concat([ + addressWorkchainBuffer, + Buffer.from(addrHash, 'hex'), + ]); + + const messageBuffer = Buffer.concat([ + Buffer.from('ton-proof-item-v2/'), + addressBuffer, + domainLengthBuffer, + domainBuffer, + timestampBuffer, + Buffer.from(payload), + ]); + const message = createHash('sha256').update(messageBuffer).digest(); + + const bufferToSign = Buffer.concat([ + Buffer.from('ffff', 'hex'), + Buffer.from('ton-connect'), + message, + ]); + const signed = nacl.sign.detached( + createHash('sha256').update(bufferToSign).digest(), + secretKey, + ); + + const signature = naclUtils.encodeBase64(signed); + return { + address, + proof: { + timestamp, + domain: { + length_bytes: domainBuffer.byteLength, + value: domain, + }, + signature, + payload, + state_init: walletStateInit, + }, + }; + } catch (e) { + throw new Error('Failed to create proof'); + } +} + +export async function signProofForTonkeeper( + addressRaw: string, + secretKey: Uint8Array, + payload: string, + walletStateInit: string, +) { + return createTonProof({ + address: addressRaw, + secretKey, + payload, + walletStateInit, + domain: 'tonkeeper.com', + }); +} diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 5820f1c6f..48ceaafac 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -94,6 +94,7 @@ android { versionCode 433 versionName "3.6" missingDimensionStrategy 'react-native-camera', 'general' + missingDimensionStrategy 'store', 'play' } compileOptions { diff --git a/packages/mobile/index.js b/packages/mobile/index.js index 1ecb53b52..69f65ebd5 100644 --- a/packages/mobile/index.js +++ b/packages/mobile/index.js @@ -21,6 +21,7 @@ import { store, 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'; LogBox.ignoreLogs([ 'Non-serializable values were found in the navigation state', @@ -64,4 +65,4 @@ setNativeExceptionHandler((exceptionString) => { store.dispatch(mainActions.init()); // tonkeeper.init(); -AppRegistry.registerComponent(appName, () => gestureHandlerRootHOC(App)); +AppRegistry.registerComponent(appName, () => withIAPContext(gestureHandlerRootHOC(App))); diff --git a/packages/mobile/package.json b/packages/mobile/package.json index e33a9df6a..e606152ed 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -30,7 +30,7 @@ "@bogoslavskiy/react-native-steezy": "^1.0.4", "@craftzdog/react-native-buffer": "^6.0.5", "@expo/react-native-action-sheet": "^4.0.1", - "@gorhom/bottom-sheet": "^4.4.7", + "@gorhom/bottom-sheet": "^4.6.0", "@rainbow-me/animated-charts": "https://github.com/tonkeeper/react-native-animated-charts#65f723604f3abc8a05ecfa2918fe9b0b42fd8363", "@react-native-async-storage/async-storage": "^1.15.5", "@react-native-community/clipboard": "^1.5.1", @@ -99,6 +99,7 @@ "react-native-gesture-handler": "^2.12.1", "react-native-get-random-values": "^1.8.0", "react-native-haptic-feedback": "^2.0.3", + "react-native-iap": "^12.10.7", "react-native-image-colors": "^2.3.0", "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-keyboard-controller": "^1.5.8", diff --git a/packages/mobile/patches/@gorhom+bottom-sheet+4.6.0.patch b/packages/mobile/patches/@gorhom+bottom-sheet+4.6.0.patch new file mode 100644 index 000000000..987b883f2 --- /dev/null +++ b/packages/mobile/patches/@gorhom+bottom-sheet+4.6.0.patch @@ -0,0 +1,130 @@ +diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx +index f20e3dc..0a0e283 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx ++++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx +@@ -692,9 +692,9 @@ const BottomSheetComponent = forwardRef( + method: animateToPosition.name, + params: { + currentPosition: animatedPosition.value, +- position, ++ nextPosition: position, + velocity, +- animatedContainerHeight: animatedContainerHeight.value, ++ source, + }, + }); + +@@ -742,6 +742,47 @@ const BottomSheetComponent = forwardRef( + }, + [handleOnAnimate, _providedAnimationConfigs] + ); ++ /** ++ * Set to position without animation. ++ * ++ * @param targetPosition position to be set. ++ */ ++ const setToPosition = useWorkletCallback(function setToPosition( ++ targetPosition: number ++ ) { ++ if ( ++ targetPosition === animatedPosition.value || ++ targetPosition === undefined || ++ (animatedAnimationState.value === ANIMATION_STATE.RUNNING && ++ targetPosition === animatedNextPosition.value) ++ ) { ++ return; ++ } ++ ++ runOnJS(print)({ ++ component: BottomSheet.name, ++ method: setToPosition.name, ++ params: { ++ currentPosition: animatedPosition.value, ++ targetPosition, ++ }, ++ }); ++ ++ /** ++ * store next position ++ */ ++ animatedNextPosition.value = targetPosition; ++ animatedNextPositionIndex.value = ++ animatedSnapPoints.value.indexOf(targetPosition); ++ ++ stopAnimation(); ++ ++ /** ++ * set position. ++ */ ++ animatedPosition.value = targetPosition; ++ }, ++ []); + //#endregion + + //#region public methods +@@ -1320,16 +1361,8 @@ const BottomSheetComponent = forwardRef( + animatedNextPositionIndex.value === -1 && + _previousContainerHeight !== containerHeight + ) { +- animationSource = ANIMATION_SOURCE.CONTAINER_RESIZE; +- animationConfig = { +- duration: 0, +- }; +- animateToPosition( +- containerHeight, +- animationSource, +- 0, +- animationConfig +- ); ++ setToPosition(containerHeight); ++ return; + } + + if ( +@@ -1370,13 +1403,11 @@ const BottomSheetComponent = forwardRef( + + /** + * if snap points changes because of the container height change, +- * then we skip the snap animation by setting the duration to 0. ++ * then we set the new position without animation. + */ + if (containerHeight !== _previousContainerHeight) { +- animationSource = ANIMATION_SOURCE.CONTAINER_RESIZE; +- animationConfig = { +- duration: 0, +- }; ++ setToPosition(nextPosition); ++ return; + } + } + animateToPosition(nextPosition, animationSource, 0, animationConfig); +@@ -1515,6 +1546,7 @@ const BottomSheetComponent = forwardRef( + }), + ({ + _animatedIndex, ++ _animatedPosition, + _animationState, + _contentGestureState, + _handleGestureState, +@@ -1526,6 +1558,21 @@ const BottomSheetComponent = forwardRef( + return; + } + ++ /** ++ * exit the method if index value is not synced with ++ * position value. ++ * ++ * [read more](https://github.com/gorhom/react-native-bottom-sheet/issues/1356) ++ */ ++ if ( ++ animatedNextPosition.value !== INITIAL_VALUE && ++ animatedNextPositionIndex.value !== INITIAL_VALUE && ++ (_animatedPosition !== animatedNextPosition.value || ++ _animatedIndex !== animatedNextPositionIndex.value) ++ ) { ++ return; ++ } ++ + /** + * exit the method if animated index value + * has fraction, e.g. 1.99, 0.52 diff --git a/packages/mobile/src/blockchain/vault.ts b/packages/mobile/src/blockchain/vault.ts index e9705efbc..be1fc8bdf 100644 --- a/packages/mobile/src/blockchain/vault.ts +++ b/packages/mobile/src/blockchain/vault.ts @@ -102,6 +102,7 @@ export class Vault { const tonPubkey = tonKeyPair.publicKey; // await tk.generateTronAddress(tonKeyPair.secretKey); + await tk.obtainProofToken(tonKeyPair); const info: VaultInfo = { name: name, @@ -509,6 +510,10 @@ export class UnlockedVault extends Vault { return keyPair.secretKey; } + 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; diff --git a/packages/mobile/src/blockchain/wallet.ts b/packages/mobile/src/blockchain/wallet.ts index 2f5b3d2ee..d4e05870f 100644 --- a/packages/mobile/src/blockchain/wallet.ts +++ b/packages/mobile/src/blockchain/wallet.ts @@ -7,9 +7,11 @@ import { getServerConfig } from '$shared/constants'; import { UnlockedVault, Vault } from './vault'; import { Address as AddressFormatter, + BASE_FORWARD_AMOUNT, ContractService, contractVersionsMap, isActiveAccount, + ONE_TON, TransactionService, } from '@tonkeeper/core'; import { debugLog } from '$utils/debugLog'; @@ -28,13 +30,30 @@ import { import { tk, tonapi } from '@tonkeeper/shared/tonkeeper'; import { Address, Cell, internal, toNano } from '@ton/core'; +import { config } from '@tonkeeper/shared/config'; +import { + emulateWithBattery, + sendBocWithBattery, +} from '@tonkeeper/shared/utils/blockchain'; import { OperationEnum, TypeEnum } from '@tonkeeper/core/src/TonAPI'; +import { setBalanceForEmulation } from '@tonkeeper/shared/utils/wallet'; const TonWeb = require('tonweb'); -export const jettonTransferAmount = toNano('0.64'); export const inscriptionTransferAmount = '0.05'; +interface JettonTransferParams { + seqno: number; + jettonWalletAddress: string; + recipient: Account; + amountNano: string; + payload: Cell | string; + vault: Vault; + secretKey?: Buffer; + excessesAccount?: string | null; + jettonTransferAmount?: bigint; +} + interface TonTransferParams { seqno: number; recipient: Account; @@ -171,6 +190,11 @@ export class TonWallet { return this.vault.getTonAddress(this.isTestnet); } + async createStateInitBase64() { + const { stateInit } = await this.vault.tonWallet.createStateInit(); + return TonWeb.utils.bytesToBase64(await stateInit.toBoc(false)); + } + async getAddressByWalletVersion(version: string) { return this.vault.getTonAddressByWalletVersion(this.tonweb, version, this.isTestnet); } @@ -200,6 +224,10 @@ export class TonWallet { } } + private async sendBoc(boc: string): Promise { + await sendBocWithBattery(boc); + } + async createSubscription( unlockedVault: UnlockedVault | Vault, beneficiaryAddress: string, @@ -299,7 +327,7 @@ export class TonWallet { const query = await tx.getQuery(); const boc = TonWeb.utils.bytesToBase64(await query.toBoc(false)); - const fee = await this.calcFee(boc); + const [fee] = await this.calcFee(boc); if (fee.isGreaterThan(myinfo.balance)) { throw new Error('Insufficient funds'); } @@ -311,10 +339,9 @@ export class TonWallet { return await this.vault.tonWallet.methods.isPluginInstalled(subscriptionAddress); } - private async calcFee(boc: string): Promise { - const estimatedTx = await tonapi.wallet.emulateMessageToWallet({ boc }); - - return new BigNumber(estimatedTx.event.extra).multipliedBy(-1); + private async calcFee(boc: string, params?): Promise<[BigNumber, boolean]> { + const { emulateResult, battery } = await emulateWithBattery(boc, params); + return [new BigNumber(emulateResult.event.extra).multipliedBy(-1), battery]; } async isInactiveAddress(address: string): Promise { @@ -333,16 +360,17 @@ export class TonWallet { return ['empty', 'uninit', 'nonexist'].includes(info?.status ?? ''); } - createJettonTransfer( - seqno: number, - jettonWalletAddress: string, - sender: Account, - recipient: Account, - amountNano: string, - payload: Cell | string = '', - vault: Vault, - secretKey: Buffer = Buffer.alloc(64), - ) { + createJettonTransfer({ + seqno, + jettonWalletAddress, + recipient, + amountNano, + payload = '', + vault, + secretKey = Buffer.alloc(64), + excessesAccount = null, + jettonTransferAmount = ONE_TON, + }: JettonTransferParams) { const version = vault.getVersion(); const lockupConfig = vault.getLockupConfig(); const contract = ContractService.getWalletContract( @@ -367,7 +395,7 @@ export class TonWallet { queryId: Date.now(), jettonAmount, receiverAddress: recipient.address, - excessesAddress: tk.wallet.address.ton.raw, + excessesAddress: excessesAccount ?? tk.wallet.address.ton.raw, forwardBody: payload, }), }), @@ -382,31 +410,33 @@ export class TonWallet { vault: Vault, payload: Cell | string = '', ) { - let sender: Account; let recipient: Account; let seqno: number; try { const fromAddress = await this.getAddress(); - sender = await this.getWalletInfo(fromAddress); recipient = await this.getWalletInfo(address); seqno = await this.getSeqno(fromAddress); } catch (e) { throw new Error(t('send_get_wallet_info_error')); } - const boc = this.createJettonTransfer( + const boc = this.createJettonTransfer({ seqno, jettonWalletAddress, - sender, recipient, amountNano, payload, vault, - ); + excessesAccount: null, + jettonTransferAmount: ONE_TON, + }); - let feeNano = await this.calcFee(boc); + let [feeNano, isBattery] = await this.calcFee( + boc, + [setBalanceForEmulation(toNano('2'))], // Emulate with higher balance to calculate fair amount to send + ); - return Ton.fromNano(feeNano.toString()); + return [Ton.fromNano(feeNano.toString()), isBattery]; } async jettonTransfer( @@ -415,6 +445,8 @@ export class TonWallet { amountNano: string, unlockedVault: UnlockedVault, payload: Cell | string = '', + sendWithBattery: boolean, + forwardAmount: string, ) { let sender: Account; let recipient: Account; @@ -440,24 +472,34 @@ export class TonWallet { throw new Error(t('send_insufficient_funds')); } - const boc = this.createJettonTransfer( + const excessesAccount = sendWithBattery + ? await tk.wallet.battery.getExcessesAccount() + : tk.wallet.address.ton.raw; + + const boc = this.createJettonTransfer({ seqno, jettonWalletAddress, - sender, recipient, amountNano, payload, - unlockedVault, - Buffer.from(secretKey), - ); + vault: unlockedVault, + secretKey: Buffer.from(secretKey), + excessesAccount, + jettonTransferAmount: BigInt(forwardAmount), + }); let feeNano: BigNumber; + let isBattery = false; try { - feeNano = await this.calcFee(boc); + const [fee, battery] = await this.calcFee(boc); + feeNano = fee; + isBattery = battery; } catch (e) { throw new Error(t('send_fee_estimation_error')); } + const transferAmount = feeNano.plus(BASE_FORWARD_AMOUNT.toString()).toString(); + if (this.isLockup()) { const lockupBalances = await this.getLockupBalances(sender); if ( @@ -466,17 +508,13 @@ export class TonWallet { throw new Error(t('send_insufficient_funds')); } } else { - if ( - new BigNumber(jettonTransferAmount.toString()) - .plus(feeNano) - .isGreaterThan(sender.balance) - ) { + if (!isBattery && new BigNumber(transferAmount).isGreaterThan(sender.balance)) { throw new Error(t('send_insufficient_funds')); } } try { - await tonapi.blockchain.sendBlockchainMessage({ boc }, { format: 'text' }); + await this.sendBoc(boc); } catch (e) { if (!store.getState().main.isTimeSynced) { throw new Error('wrong_time'); @@ -585,9 +623,9 @@ export class TonWallet { : false, }); - let feeNano = await this.calcFee(boc); + const [feeNano, isBattery] = await this.calcFee(boc); - return Ton.fromNano(feeNano.toString()); + return [Ton.fromNano(feeNano.toString()), isBattery]; } async deploy(unlockedVault: UnlockedVault) { @@ -668,7 +706,8 @@ export class TonWallet { let feeNano: BigNumber; try { - feeNano = await this.calcFee(boc); + const [fee] = await this.calcFee(boc); + feeNano = fee; } catch (e) { feeNano = new BigNumber('0'); debugLog('[Transfer]: error estimate fee', e); @@ -686,7 +725,7 @@ export class TonWallet { } try { - await tonapi.blockchain.sendBlockchainMessage({ boc }, { format: 'text' }); + await this.sendBoc(boc); } catch (e) { if (!store.getState().main.isTimeSynced) { throw new Error('wrong_time'); diff --git a/packages/mobile/src/core/AccessConfirmation/AccessConfirmation.tsx b/packages/mobile/src/core/AccessConfirmation/AccessConfirmation.tsx index 29f81bf06..5d5dd596f 100644 --- a/packages/mobile/src/core/AccessConfirmation/AccessConfirmation.tsx +++ b/packages/mobile/src/core/AccessConfirmation/AccessConfirmation.tsx @@ -24,9 +24,9 @@ import { Toast, ToastSize } from '$store'; import { goBack, useParams } from '$navigation/imperative'; import { t } from '@tonkeeper/shared/i18n'; -import { createTronOwnerAddress } from '@tonkeeper/core/src/utils/tronUtils'; import { tk } from '@tonkeeper/shared/tonkeeper'; import { CanceledActionError } from '$core/Send/steps/ConfirmStep/ActionErrors'; +import nacl from 'tweetnacl'; export const AccessConfirmation: FC = () => { const route = useRoute(); @@ -73,6 +73,20 @@ export const AccessConfirmation: FC = () => { } }, []); + const obtainTonProof = useCallback(async (keyPair: nacl.SignKeyPair) => { + try { + // Obtain tonProof + if (!tk.wallet.identity.tonProof) { + const tonProof = await tk.obtainProofToken(keyPair); + if (tonProof) { + tk.wallet.setTonProof(tonProof); + } + } + } catch (err) { + console.error('[obtain tonProof]', err); + } + }, []); + const handleKeyboard = useCallback( (newValue) => { const pin = newValue.substr(0, 4); @@ -118,10 +132,9 @@ export const AccessConfirmation: FC = () => { setTimeout(async () => { if (isUnlock) { - const privateKey = await ( - unlockedVault as any - ).getTonPrivateKey(); + const keyPair = await (unlockedVault as any).getKeyPair(); // createTronAddress(privateKey); + obtainTonProof(keyPair); dispatch(mainActions.setUnlocked(true)); } else { @@ -162,9 +175,6 @@ export const AccessConfirmation: FC = () => { setTimeout(async () => { // Lock screen if (isUnlock) { - const privateKey = await (unlockedVault as any).getTonPrivateKey(); - // createTronAddress(privateKey); - dispatch(mainActions.setUnlocked(true)); } else { goBack(); diff --git a/packages/mobile/src/core/DevMenu/DevConfigScreen.tsx b/packages/mobile/src/core/DevMenu/DevConfigScreen.tsx index c9e41db5a..46688cd35 100644 --- a/packages/mobile/src/core/DevMenu/DevConfigScreen.tsx +++ b/packages/mobile/src/core/DevMenu/DevConfigScreen.tsx @@ -1,4 +1,4 @@ -import { config } from '@tonkeeper/shared/config'; +import { AppConfigVars, config } from '@tonkeeper/shared/config'; import { useNavigation } from '@tonkeeper/router'; import { memo, useRef, useState } from 'react'; import RNRestart from 'react-native-restart'; @@ -11,14 +11,22 @@ import { Screen, Spacer, Steezy, + useTheme, } from '@tonkeeper/uikit'; +import { Switch } from 'react-native'; export const DevConfigScreen = memo(() => { const [_, rerender] = useState(0); const nav = useNavigation(); + const theme = useTheme(); const handleSave = () => rerender((c) => c + 1); + const handleBooleanSwitch = (key: keyof AppConfigVars) => () => { + config.set({ [key]: !config.get(key) }); + handleSave(); + }; + return ( { }) } /> + + } + /> + + } + /> + + } + /> @@ -104,4 +145,4 @@ const styles = Steezy.create(({ colors }) => ({ value: { color: colors.accentBlue, }, -})); \ No newline at end of file +})); diff --git a/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.style.ts b/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.style.ts index 6bf801cb1..a0da5c730 100644 --- a/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.style.ts +++ b/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.style.ts @@ -29,14 +29,6 @@ const radius = (topRadius: boolean, bottomRadius: boolean) => { `; }; -export const Card = styled(Pressable).attrs(({ theme }) => ({ - underlayColor: theme.colors.backgroundTertiary, -}))<{ 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; diff --git a/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.tsx b/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.tsx index 6b9c1cad3..a19703462 100644 --- a/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.tsx +++ b/packages/mobile/src/core/Exchange/ExchangeItem/ExchangeItem.tsx @@ -9,6 +9,7 @@ import { Linking } from 'react-native'; import { t } from '@tonkeeper/shared/i18n'; import { openExchangeMethodModal } from '$core/ModalContainer/ExchangeMethod/ExchangeMethod'; import { getCryptoAssetIconSource } from '@tonkeeper/uikit/assets/cryptoAssets'; +import { Pressable, Steezy } from '@tonkeeper/uikit'; export const ExchangeItem: FC = ({ methodId, @@ -59,11 +60,15 @@ export const ExchangeItem: FC = ({ return ( - @@ -103,9 +108,24 @@ export const ExchangeItem: FC = ({ - + {renderBadge()} {!bottomRadius ? : null} ); }; + +const styles = Steezy.create({ + cardPressable: { + overflow: 'hidden', + padding: 16, + }, + topBorderRadius: { + borderTopStartRadius: 16, + borderTopEndRadius: 16, + }, + bottomBorderRadius: { + borderBottomStartRadius: 16, + borderBottomEndRadius: 16, + }, +}); diff --git a/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx b/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx index 24db50659..318febdfd 100644 --- a/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx +++ b/packages/mobile/src/core/ModalContainer/InsufficientFunds/InsufficientFunds.tsx @@ -1,6 +1,6 @@ import React, { memo, useCallback, useMemo } from 'react'; import { t } from '@tonkeeper/shared/i18n'; -import { Modal } from '@tonkeeper/uikit'; +import { Modal, Spacer } from '@tonkeeper/uikit'; import { openExploreTab } from '$navigation'; import { SheetActions, useNavigation } from '@tonkeeper/router'; import { Button, Icon, Text } from '$uikit'; @@ -12,6 +12,9 @@ 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 { openRefillBatteryModal } from '@tonkeeper/shared/modals/RefillBatteryModal'; export interface InsufficientFundsParams { /** @@ -45,6 +48,7 @@ export const InsufficientFundsModal = memo((props) => { fee, isStakingDeposit, } = props; + const { balance: batteryBalance } = useBatteryBalance(); const nav = useNavigation(); const formattedAmount = useMemo( () => formatter.format(fromNano(totalAmount, decimals), { decimals }), @@ -54,6 +58,8 @@ export const InsufficientFundsModal = memo((props) => { () => formatter.format(fromNano(balance, decimals), { decimals }), [balance, decimals], ); + const shouldShowRefillBatteryButton = + !config.get('disable_battery') && currency === 'TON' && batteryBalance === '0'; const handleOpenRechargeWallet = useCallback(async () => { nav.goBack(); @@ -61,6 +67,12 @@ export const InsufficientFundsModal = memo((props) => { nav.openModal('Exchange'); }, [nav]); + const handleOpenRefillBattery = useCallback(async () => { + nav.goBack(); + await delay(550); + openRefillBatteryModal(); + }, [nav]); + const handleOpenDappBrowser = useCallback(async () => { nav.goBack(); await delay(550); @@ -114,24 +126,34 @@ export const InsufficientFundsModal = memo((props) => { - - + + + {t('txActions.signRaw.insufficientFunds.title')} + {content} + {shouldShowRefillBatteryButton && ( + <> + + + + )} + diff --git a/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx b/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx index a6b6d74fb..bc8c7c596 100644 --- a/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx +++ b/packages/mobile/src/core/ModalContainer/NFTOperations/Modals/SignRawModal.tsx @@ -6,7 +6,7 @@ import { calculateMessageTransferAmount, delay } from '$utils'; import { debugLog } from '$utils/debugLog'; import { t } from '@tonkeeper/shared/i18n'; import { store, Toast } from '$store'; -import { List, Modal, Steezy, Text, View } from '@tonkeeper/uikit'; +import { List, Modal, Spacer, Steezy, Text, View } from '@tonkeeper/uikit'; import { push } from '$navigation/imperative'; import { SheetActions } from '@tonkeeper/router'; import { @@ -45,11 +45,19 @@ interface SignRawModalProps { onSuccess?: (boc: string) => void; onDismiss?: () => void; redirectToActivity?: boolean; + isBattery?: boolean; } export const SignRawModal = memo((props) => { - const { options, params, onSuccess, onDismiss, consequences, redirectToActivity } = - props; + const { + options, + params, + onSuccess, + onDismiss, + consequences, + isBattery, + redirectToActivity, + } = props; const { footerRef, onConfirm } = useNFTOperationState(options); const unlockVault = useUnlockVault(); @@ -73,12 +81,9 @@ export const SignRawModal = memo((props) => { secretKey: Buffer.from(privateKey), }); - await tonapi.blockchain.sendBlockchainMessage( - { - boc, - }, - { format: 'text' }, - ); + await tonapi.blockchain.sendBlockchainMessage({ + boc, + }); if (onSuccess) { trackEvent(Events.SendSuccess, { from: SendAnalyticsFrom.SignRaw }); @@ -153,7 +158,7 @@ export const SignRawModal = memo((props) => { }), }; } - }, [consequences]); + }, [consequences, fiatCurrency, getTokenPrice]); const amountToFiat = (action: AnyActionItem) => { if (action.amount) { @@ -206,6 +211,14 @@ export const SignRawModal = memo((props) => { ≈ {extra.value} · {extra.fiat} + {isBattery && ( + + + {t('confirmSendModal.will_be_paid_with_battery')} + + + )} + = (props) => { const [consequences, setConsequences] = useState(null); const [isPreparing, setPreparing] = useState(false); const [isSending, setSending] = useState(false); + const [isBattery, setIsBattery] = useState(false); const [isCommentEncrypted, setCommentEncrypted] = useState(false); const isBackDisabled = isSending || isPreparing; @@ -148,12 +153,13 @@ export const NFTSend: FC = (props) => { secretKey: Buffer.alloc(64), }); - const response = await tonapi.wallet.emulateMessageToWallet({ + const response = await emulateWithBattery( boc, - params: [setBalanceForEmulation(toNano('2'))], // Emulate with higher balance to calculate fair amount to send - }); + [setBalanceForEmulation(toNano('2'))], // Emulate with higher balance to calculate fair amount to send + ); - setConsequences(response); + setConsequences(response.emulateResult); + setIsBattery(response.battery); Keyboard.dismiss(); await delay(100); @@ -253,7 +259,7 @@ export const NFTSend: FC = (props) => { : BigInt(Math.abs(consequences?.event.extra!)) + BASE_FORWARD_AMOUNT; const checkResult = await checkIsInsufficient(totalAmount.toString()); - if (checkResult.insufficient) { + if (!isBattery && checkResult.insufficient) { openInsufficientFundsModal({ totalAmount: totalAmount.toString(), balance: checkResult.balance, @@ -263,6 +269,8 @@ export const NFTSend: FC = (props) => { throw new CanceledActionError(); } + const excessesAccount = isBattery && (await tk.wallet.battery.getExcessesAccount()); + const nftTransferMessages = [ internal({ to: nftAddress, @@ -270,7 +278,7 @@ export const NFTSend: FC = (props) => { body: ContractService.createNftTransferBody({ queryId: Date.now(), newOwnerAddress: recipient!.address, - excessesAddress: tk.wallet.address.ton.raw, + excessesAddress: excessesAccount || tk.wallet.address.ton.raw, forwardBody: commentValue, }), bounce: true, @@ -289,12 +297,7 @@ export const NFTSend: FC = (props) => { secretKey: Buffer.from(privateKey), }); - await tonapi.blockchain.sendBlockchainMessage( - { - boc, - }, - { format: 'text' }, - ); + await sendBocWithBattery(boc); } catch (e) { throw e; } finally { @@ -303,6 +306,7 @@ export const NFTSend: FC = (props) => { }, [ comment, consequences?.event.extra, + isBattery, isCommentEncrypted, nftAddress, recipient, @@ -354,6 +358,7 @@ export const NFTSend: FC = (props) => { {(stepProps) => ( >; isPreparing: boolean; + isBattery: boolean; isCommentEncrypted: boolean; comment: string; sendTx: () => Promise; @@ -41,6 +44,7 @@ const ConfirmStepComponent: FC = (props) => { nftName, nftIcon, nftCollection, + isBattery, comment, total, stepsScrollTop, @@ -50,6 +54,7 @@ const ConfirmStepComponent: FC = (props) => { } = props; const fiatFee = useFiatValue(CryptoCurrencies.Ton, total?.amount || '0'); + const batteryState = useBatteryState(); const { bottom: bottomInset } = useSafeAreaInsets(); @@ -119,13 +124,13 @@ const ConfirmStepComponent: FC = (props) => { - - - {total.isRefund - ? t('nft_transfer.confirm.fee.refund_label') - : t('nft_transfer.confirm.fee.label')} - - + + + + {total.isRefund + ? t('nft_transfer.confirm.fee.refund_label') + : t('nft_transfer.confirm.fee.label')} + {isPreparing ? ( <> @@ -143,13 +148,22 @@ const ConfirmStepComponent: FC = (props) => { value: total.amount ? truncateDecimal(total.amount, 1) : '?', })} - - {total?.amount ? `≈ ${fiatFee.formatted.totalFiat}` : ' '} - )} - - + + + {batteryState !== BatteryState.Empty && isBattery ? ( + + {t('send_screen_steps.comfirm.will_be_paid_with_battery')} + + ) : ( + + )} + + {total?.amount ? `≈ ${fiatFee.formatted.totalFiat}` : ' '} + + + {comment.length > 0 ? ( <> diff --git a/packages/mobile/src/core/RefillBattery/RefillBattery.tsx b/packages/mobile/src/core/RefillBattery/RefillBattery.tsx new file mode 100644 index 000000000..4b1a2df04 --- /dev/null +++ b/packages/mobile/src/core/RefillBattery/RefillBattery.tsx @@ -0,0 +1,17 @@ +import { memo } from 'react'; +import { RefillBattery as RefillBatteryComponent } from '@tonkeeper/shared/components/RefillBattery/RefillBattery'; +import { t } from '@tonkeeper/shared/i18n'; +import { ScrollHandler } from '$uikit'; + +export const RefillBattery = memo(() => { + return ( + + + + ); +}); diff --git a/packages/mobile/src/core/Send/Send.tsx b/packages/mobile/src/core/Send/Send.tsx index 22afabf7b..f8095cb5e 100644 --- a/packages/mobile/src/core/Send/Send.tsx +++ b/packages/mobile/src/core/Send/Send.tsx @@ -127,6 +127,7 @@ export const Send: FC = ({ route }) => { const [fee, setFee] = useState(initialFee); const [isInactive, setInactive] = useState(initialIsInactive); + const [isBattery, setBattery] = useState(false); const [insufficientFundsParams, setInsufficientFundsParams] = useState(null); @@ -240,6 +241,7 @@ export const Send: FC = ({ route }) => { setInsufficientFundsParams(null); setFee(details.fee); setInactive(details.isInactive); + setBattery(details.isBattery); stepViewRef.current?.go(SendSteps.CONFIRM); }, @@ -298,6 +300,7 @@ export const Send: FC = ({ route }) => { if (recipient.blockchain === 'ton') { dispatch( walletActions.sendCoins({ + fee, currencyAdditionalParams: currencyAdditionalParams, currency: currency as CryptoCurrency, amount: parsedAmount, @@ -308,6 +311,7 @@ export const Send: FC = ({ route }) => { tokenType, jettonWalletAddress, decimals, + sendWithBattery: isBattery, onDone: () => { trackEvent(Events.SendSuccess, { from }); dispatch(favoritesActions.restoreHiddenAddress(recipient.address)); @@ -333,20 +337,23 @@ export const Send: FC = ({ route }) => { } }, [ - amount.all, - comment, - currency, - decimals, - dispatch, + fee, + recipient, expiryTimestamp, - from, insufficientFundsParams, + trcPayload.value, + isBattery, + dispatch, + currencyAdditionalParams, + currency, + parsedAmount, + amount.all, + comment, isCommentEncrypted, tokenType, jettonWalletAddress, - parsedAmount, - recipient, - trcPayload.value, + decimals, + from, unlock, ], ); @@ -445,7 +452,6 @@ export const Send: FC = ({ route }) => { ref={stepViewRef} backDisabled={isBackDisabled} onChangeStep={handleChangeStep} - initialStepId={initialStepId} useBackHandler swipeBackEnabled={!isBackDisabled} > @@ -455,7 +461,6 @@ export const Send: FC = ({ route }) => { enableEncryption={tokensWithAllowedEncryption.includes(tokenType)} recipient={recipient} decimals={decimals} - stepsScrollTop={stepsScrollTop} changeBlockchain={changeBlockchain} setRecipient={setRecipient} setRecipientAccountInfo={setRecipientAccountInfo} @@ -493,7 +498,8 @@ export const Send: FC = ({ route }) => { {(stepProps) => ( = (props) => { setRecipient, changeBlockchain, active, - enableEncryption = true, + enableEncryption = false, setRecipientAccountInfo, setAmount, setComment, diff --git a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.interface.ts b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.interface.ts index 8c56f942b..cc1e504a1 100644 --- a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.interface.ts +++ b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.interface.ts @@ -12,6 +12,7 @@ import { CryptoCurrency } from '$shared/constants'; export interface ConfirmStepProps { active: boolean; currencyTitle: string; + isBattery: boolean; currency: CryptoCurrency | string; recipient: SendRecipient | null; recipientAccountInfo: AccountWithPubKey | null; diff --git a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.style.ts b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.style.ts index 80241ddb3..6477a0a47 100644 --- a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.style.ts +++ b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.style.ts @@ -29,11 +29,27 @@ export const ItemInline = styled.View` align-items: center; `; +export const ItemSubLabel = styled(Text).attrs({ + color: 'textTertiary', + variant: 'body2', +})``; + export const ItemLabel = styled(Text).attrs({ color: 'foregroundSecondary', variant: 'body1', })``; +export const ItemRowContainer = styled.View` + padding: ${ns(16)}px 0; +`; + +export const ItemRow = styled.View` + padding: 0 ${ns(16)}px; + flex: 1; + flex-direction: row; + justify-content: space-between; +`; + export const ItemContent = styled.View` flex: 1; padding-left: ${ns(16)}px; diff --git a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx index 4f0c98ed0..d70aecb3f 100644 --- a/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx +++ b/packages/mobile/src/core/Send/steps/ConfirmStep/ConfirmStep.tsx @@ -23,6 +23,8 @@ import { SkeletonLine } from '$uikit/Skeleton/SkeletonLine'; import { t } from '@tonkeeper/shared/i18n'; import { openInactiveInfo } from '$core/ModalContainer/InfoAboutInactive/InfoAboutInactive'; 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'; const ConfirmStepComponent: FC = (props) => { @@ -37,6 +39,7 @@ const ConfirmStepComponent: FC = (props) => { fee, active, isInactive, + isBattery, amount, comment, isCommentEncrypted, @@ -52,6 +55,7 @@ const ConfirmStepComponent: FC = (props) => { const balances = useSelector(walletBalancesSelector); const wallet = useSelector(walletWalletSelector); + const batteryState = useBatteryState(); const { Logo, liquidJettonPool } = useCurrencyToSend(currency, tokenType); @@ -267,9 +271,9 @@ const ConfirmStepComponent: FC = (props) => { - - {t('confirm_sending_fee')} - + + + {t('confirm_sending_fee')} {isPreparing ? ( @@ -277,11 +281,20 @@ const ConfirmStepComponent: FC = (props) => { ) : ( {feeValue} )} + + + {batteryState !== BatteryState.Empty && isBattery ? ( + + {t('send_screen_steps.comfirm.will_be_paid_with_battery')} + + ) : ( + + )} {fee !== '0' && !isPreparing ? ( ≈ {fiatFee.formatted.totalFiat} ) : null} - - + + {comment.length > 0 ? ( <> diff --git a/packages/mobile/src/core/Settings/Settings.tsx b/packages/mobile/src/core/Settings/Settings.tsx index 6b13a31f0..6685b93ec 100644 --- a/packages/mobile/src/core/Settings/Settings.tsx +++ b/packages/mobile/src/core/Settings/Settings.tsx @@ -8,19 +8,21 @@ import Animated from 'react-native-reanimated'; import { TapGestureHandler } from 'react-native-gesture-handler'; import * as S from './Settings.style'; -import { Icon, PopupSelect, ScrollHandler, Spacer, Text, List } from '$uikit'; +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 { MainStackRouteNames, openDeleteAccountDone, openDevMenu, - openJettonsListSettingsStack, openLegalDocuments, openManageTokens, openNotifications, + openRefillBattery, openSecurity, openSecurityMigration, openSubscriptions, @@ -56,6 +58,7 @@ 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'; export const Settings: FC = () => { const animationRef = useRef(null); @@ -84,6 +87,8 @@ export const Settings: FC = () => { const shouldShowTokensButton = useShouldShowTokensButton(); const showNotifications = useNotificationsStore(shouldShowNotifications); + const isBatteryVisible = !config.get('disable_battery'); + const searchEngine = useBrowserStore((state) => state.searchEngine); const setSearchEngine = useBrowserStore((state) => state.actions.setSearchEngine); @@ -202,14 +207,14 @@ export const Settings: FC = () => { openAppearance(); }, []); - const handleJettonsList = useCallback(() => { - openJettonsListSettingsStack(); - }, []); - const handleManageTokens = useCallback(() => { openManageTokens(); }, []); + const handleBattery = useCallback(() => { + openRefillBattery(); + }, []); + const handleDeleteAccount = useCallback(() => { Alert.alert(t('settings_delete_alert_title'), t('settings_delete_alert_caption'), [ { @@ -310,6 +315,19 @@ export const Settings: FC = () => { onPress={handleAppearance} /> )} + {isBatteryVisible && ( + + } + title={t('battery.settings')} + onPress={handleBattery} + /> + )} diff --git a/packages/mobile/src/navigation/ModalStack.tsx b/packages/mobile/src/navigation/ModalStack.tsx index da741d1a8..8bc6604dd 100644 --- a/packages/mobile/src/navigation/ModalStack.tsx +++ b/packages/mobile/src/navigation/ModalStack.tsx @@ -33,6 +33,7 @@ import { ProvidersWithNavigation } from './Providers'; import { ReceiveModal } from '@tonkeeper/shared/modals/ReceiveModal'; import { ReceiveJettonModal } from '@tonkeeper/shared/modals/ReceiveJettonModal'; import { EditAppConfigModal } from '$core/DevMenu/DevConfigScreen'; +import { RefillBatteryModal } from '../../../shared/modals/RefillBatteryModal'; import { NFTSend } from '$core/NFTSend/NFTSend'; import { ReceiveInscriptionModal } from '@tonkeeper/shared/modals/ReceiveInscriptionModal'; @@ -49,6 +50,7 @@ export const ModalStack = React.memo(() => ( + diff --git a/packages/mobile/src/navigation/SettingsStack/SettingsStack.interface.ts b/packages/mobile/src/navigation/SettingsStack/SettingsStack.interface.ts index 45e252771..61ff279d2 100644 --- a/packages/mobile/src/navigation/SettingsStack/SettingsStack.interface.ts +++ b/packages/mobile/src/navigation/SettingsStack/SettingsStack.interface.ts @@ -11,4 +11,5 @@ export type SettingsStackParamList = { [SettingsStackRouteNames.Notifications]: {}; [SettingsStackRouteNames.JettonsList]: {}; [SettingsStackRouteNames.ChooseCurrency]: {}; + [SettingsStackRouteNames.RefillBattery]: {}; }; diff --git a/packages/mobile/src/navigation/SettingsStack/SettingsStack.tsx b/packages/mobile/src/navigation/SettingsStack/SettingsStack.tsx index f93705946..2d85822e7 100644 --- a/packages/mobile/src/navigation/SettingsStack/SettingsStack.tsx +++ b/packages/mobile/src/navigation/SettingsStack/SettingsStack.tsx @@ -17,6 +17,7 @@ import { Notifications } from '$core/Notifications/Notifications'; import { JettonsList } from '$core/JettonsList/JettonsList'; import { ChooseCurrencyScreen } from '$core/ChooseCurrencyScreen'; import { DevConfigScreen } from '$core/DevMenu/DevConfigScreen'; +import { RefillBattery } from '$core/RefillBattery/RefillBattery'; const Stack = createNativeStackNavigator(); @@ -56,6 +57,10 @@ export const SettingsStack: FC = () => { }, }} /> + { push(MainStackRouteNames.ManageTokens, { initialTab }); diff --git a/packages/mobile/src/navigation/hooks/useDeeplinkingResolvers.ts b/packages/mobile/src/navigation/hooks/useDeeplinkingResolvers.ts index 437f5f656..3b1bc719a 100644 --- a/packages/mobile/src/navigation/hooks/useDeeplinkingResolvers.ts +++ b/packages/mobile/src/navigation/hooks/useDeeplinkingResolvers.ts @@ -43,6 +43,7 @@ import { useMethodsToBuyStore } from '$store/zustand/methodsToBuy/useMethodsToBu import { isMethodIdExists } from '$store/zustand/methodsToBuy/helpers'; import { openActivityActionModal } from '@tonkeeper/shared/modals/ActivityActionModal'; import { tk } from '@tonkeeper/shared/tonkeeper'; +import { config } from '@tonkeeper/shared/config'; import { TokenType } from '$core/Send/Send.interface'; const getWallet = () => { @@ -457,6 +458,12 @@ export function useDeeplinkingResolvers() { if (!Address.isValid(query.nft)) { return Toast.fail(t('transfer_deeplink_nft_address_error')); } + const excessesAccount = + !config.get('disable_battery_send') && + tk.wallet.battery.state.data.balance !== '0' + ? await tk.wallet.battery.getExcessesAccount() + : null; + await openSignRawModal( { messages: [ @@ -466,7 +473,7 @@ export function useDeeplinkingResolvers() { payload: ContractService.createNftTransferBody({ queryId: Date.now(), newOwnerAddress: address, - excessesAddress: tk.wallet.address.ton.raw, + excessesAddress: excessesAccount || tk.wallet.address.ton.raw, }) .toBoc() .toString('base64'), diff --git a/packages/mobile/src/navigation/navigationNames.ts b/packages/mobile/src/navigation/navigationNames.ts index 64c01d840..9f6b5e5ed 100644 --- a/packages/mobile/src/navigation/navigationNames.ts +++ b/packages/mobile/src/navigation/navigationNames.ts @@ -87,6 +87,7 @@ export enum SettingsStackRouteNames { FontLicense = 'FontLicense', Notifications = 'Notifications', ChooseCurrency = 'ChooseCurrency', + RefillBattery = 'RefillBattery', } export enum ActivityStackRouteNames { diff --git a/packages/mobile/src/store/main/sagas.ts b/packages/mobile/src/store/main/sagas.ts index c70a25ec3..dd8d08510 100644 --- a/packages/mobile/src/store/main/sagas.ts +++ b/packages/mobile/src/store/main/sagas.ts @@ -223,11 +223,14 @@ export function* initHandler(isTestnet: boolean, canRetry = false) { const { wallet: walletNew } = yield select(walletSelector); const addr = yield call([walletNew.ton, 'getAddress']); const data = yield call([tk, 'load']); + const stateInit = yield call([walletNew.ton, 'createStateInitBase64']); yield call( [tk, 'init'], addr, isTestnet, data.tronAddress, + data.tonProof, + stateInit, !getFlag('address_style_nobounce'), ); useSwapStore.getState().actions.fetchAssets(); diff --git a/packages/mobile/src/store/nfts/sagas.ts b/packages/mobile/src/store/nfts/sagas.ts index cf582082b..a79088723 100644 --- a/packages/mobile/src/store/nfts/sagas.ts +++ b/packages/mobile/src/store/nfts/sagas.ts @@ -149,6 +149,7 @@ export function* loadMarketplacesWorker() { }, ); const marketplaces: MarketplaceModel[] = resp?.data?.data?.marketplaces || []; + yield put(nftsActions.setLoadedMarketplaces(marketplaces)); } catch (e) {} } diff --git a/packages/mobile/src/store/wallet/interface.ts b/packages/mobile/src/store/wallet/interface.ts index 43f631648..86e493e1c 100644 --- a/packages/mobile/src/store/wallet/interface.ts +++ b/packages/mobile/src/store/wallet/interface.ts @@ -48,7 +48,7 @@ export type ConfirmSendCoinsAction = PayloadAction<{ isCommentEncrypted?: boolean; onEnd?: () => void; onInsufficientFunds?: (params: InsufficientFundsParams) => void; - onNext: (info: { fee: string; isInactive: boolean }) => void; + onNext: (info: { fee: string; isInactive: boolean; isBattery: boolean }) => void; tokenType?: TokenType; isSendAll?: boolean; decimals?: number; @@ -56,6 +56,7 @@ export type ConfirmSendCoinsAction = PayloadAction<{ currencyAdditionalParams?: CurrencyAdditionalParams; }>; export type SendCoinsAction = PayloadAction<{ + fee: string; currency: CryptoCurrency; amount: string; address: string; @@ -67,6 +68,7 @@ export type SendCoinsAction = PayloadAction<{ decimals?: number; onDone: () => void; onFail: () => void; + sendWithBattery?: boolean; currencyAdditionalParams?: CurrencyAdditionalParams; }>; export type ChangeBalanceAndReloadAction = PayloadAction<{ diff --git a/packages/mobile/src/store/wallet/sagas.ts b/packages/mobile/src/store/wallet/sagas.ts index 18ab035bb..cd822fea4 100644 --- a/packages/mobile/src/store/wallet/sagas.ts +++ b/packages/mobile/src/store/wallet/sagas.ts @@ -14,13 +14,7 @@ import * as LocalAuthentication from 'expo-local-authentication'; import * as SecureStore from 'expo-secure-store'; import { walletActions, walletSelector, walletWalletSelector } from '$store/wallet/index'; -import { - EncryptedVault, - jettonTransferAmount, - UnlockedVault, - Vault, - Wallet, -} from '$blockchain'; +import { EncryptedVault, UnlockedVault, Vault, Wallet } from '$blockchain'; import { mainActions } from '$store/main'; import { CryptoCurrencies, PrimaryCryptoCurrencies } from '$shared/constants'; import { @@ -90,7 +84,7 @@ import { clearSubscribeStatus } from '$utils/messaging'; import { useRatesStore } from '$store/zustand/rates'; import { Cell } from '@ton/core'; import nacl from 'tweetnacl'; -import { encryptMessageComment } from '@tonkeeper/core'; +import { BASE_FORWARD_AMOUNT, encryptMessageComment } from '@tonkeeper/core'; import { goBack } from '$navigation/imperative'; import { trackEvent } from '$utils/stats'; import { tk } from '@tonkeeper/shared/tonkeeper'; @@ -176,11 +170,14 @@ function* createWalletWorker(action: CreateWalletAction) { yield fork(loadRatesAfterJettons); const addr = yield call([wallet.ton, 'getAddress']); const data = yield call([tk, 'load']); + const stateInit = yield call([wallet.ton, 'createStateInitBase64']); yield call( [tk, 'init'], addr, getChainName() === 'testnet', data.tronAddress, + data.tonProof, + stateInit, !getFlag('address_style_nobounce'), ); onDone(); @@ -307,11 +304,15 @@ function* switchVersionWorker() { const addr = yield call([newWallet.ton, 'getAddress']); yield call([tk, 'destroy']); const data = yield call([tk, 'load']); + const stateInit = yield call([newWallet.ton, 'createStateInitBase64']); + yield call( [tk, 'init'], addr, getChainName() === 'testnet', data.tronAddress, + data.tonProof, + stateInit, !getFlag('address_style_nobounce'), ); @@ -332,6 +333,7 @@ function* refreshBalancesPageWorker(action: RefreshBalancesPageAction) { yield call(setLastRefreshedAt, Date.now()); yield put(subscriptionsActions.loadSubscriptions()); yield call([tk.wallet.tonInscriptions, 'getInscriptions']); + yield call([tk.wallet.battery, 'fetchBalance']); } catch (e) { yield put(walletActions.endRefreshBalancesPage()); } @@ -388,10 +390,11 @@ function* confirmSendCoinsWorker(action: ConfirmSendCoinsAction) { let isUninit = false; let fee: string = '0'; + let isBattery = false; let isEstimateFeeError = false; try { if (tokenType === TokenType.Jetton) { - fee = yield call( + const [estimatedFee, battery] = yield call( [wallet.ton, 'estimateJettonFee'], jettonWalletAddress, address, @@ -399,21 +402,23 @@ function* confirmSendCoinsWorker(action: ConfirmSendCoinsAction) { wallet.vault, commentValue, ); + fee = estimatedFee; + isBattery = battery; } else if (tokenType === TokenType.TON) { - if (currency === CryptoCurrencies.Ton) { - fee = yield call( - [wallet.ton, 'estimateFee'], - address, - amount, - wallet.vault, - commentValue, - isSendAll ? 128 : 3, - ); - isUninit = yield call([wallet.ton, 'isInactiveAddress'], address); - } + const [estimatedFee, battery] = yield call( + [wallet.ton, 'estimateFee'], + address, + amount, + wallet.vault, + commentValue, + isSendAll ? 128 : 3, + ); + fee = estimatedFee; + isBattery = battery; + isUninit = yield call([wallet.ton, 'isInactiveAddress'], address); } else if (tokenType === TokenType.Inscription) { const type = (currencyAdditionalParams as InscriptionAdditionalParams).type; - fee = yield call( + const [estimatedFee, battery] = yield call( [wallet.ton, 'estimateInscriptionFee'], currency, type, @@ -422,6 +427,8 @@ function* confirmSendCoinsWorker(action: ConfirmSendCoinsAction) { wallet.vault, commentValue, ); + fee = estimatedFee; + isBattery = battery; } } catch (e) { console.log(e); @@ -441,20 +448,20 @@ function* confirmSendCoinsWorker(action: ConfirmSendCoinsAction) { yield delay(100); if (onNext) { - if (isEstimateFeeError && onInsufficientFunds) { + if ((tokenType !== TokenType.TON || !isSendAll) && onInsufficientFunds) { const amountNano = tokenType === TokenType.Jetton - ? jettonTransferAmount.toString() - : toNano(amount); + ? new BigNumber(toNano(fee)).plus(BASE_FORWARD_AMOUNT.toString()).toString() + : new BigNumber(toNano(fee)).plus(toNano(amount)).toString(); const address = yield call([wallet.ton, 'getAddress']); const { balance } = yield call(Tonapi.getWalletInfo, address); if (new BigNumber(amountNano).gt(new BigNumber(balance))) { return onInsufficientFunds({ totalAmount: amountNano, balance }); } else { - yield call(onNext, { fee, isInactive: isUninit }); + yield call(onNext, { fee, isInactive: isUninit, isBattery }); } } else { - yield call(onNext, { fee, isInactive: isUninit }); + yield call(onNext, { fee, isInactive: isUninit, isBattery }); } } @@ -479,12 +486,14 @@ function* sendCoinsWorker(action: SendCoinsAction) { address, comment, isCommentEncrypted, + fee, onDone, onFail, isSendAll, tokenType, jettonWalletAddress, decimals, + sendWithBattery, currencyAdditionalParams, } = action.payload; @@ -521,6 +530,8 @@ function* sendCoinsWorker(action: SendCoinsAction) { toNano(amount, decimals), unlockedVault, commentValue, + sendWithBattery, + BigNumber(toNano(fee)).plus(BASE_FORWARD_AMOUNT.toString()).toString(), ); } else if (tokenType === TokenType.TON && currency === CryptoCurrencies.Ton) { yield call( @@ -926,11 +937,14 @@ function* doMigration(wallet: Wallet, newAddress: string) { const addr = yield call([newWallet.ton, 'getAddress']); yield call([tk, 'destroy']); const data = yield call([tk, 'load']); + const stateInit = yield call([newWallet.ton, 'createStateInitBase64']); yield call( [tk, 'init'], addr, getChainName() === 'testnet', data.tronAddress, + data.tonProof, + stateInit, !getFlag('address_style_nobounce'), ); diff --git a/packages/mobile/src/tabs/Wallet/WalletScreen.tsx b/packages/mobile/src/tabs/Wallet/WalletScreen.tsx index 67d9abc53..57242bef8 100644 --- a/packages/mobile/src/tabs/Wallet/WalletScreen.tsx +++ b/packages/mobile/src/tabs/Wallet/WalletScreen.tsx @@ -9,6 +9,8 @@ import { List, View, PagerView, + Spacer, + copyText, } from '@tonkeeper/uikit'; import { InternalNotification } from '$uikit'; import { useNavigation } from '@tonkeeper/router'; @@ -18,13 +20,11 @@ import { NFTCardItem } from './NFTCardItem'; import { useDispatch, useSelector } from 'react-redux'; import { ns } from '$utils'; import { walletActions, walletSelector, walletUpdatedAtSelector } from '$store/wallet'; -import { copyText } from '$hooks/useCopyText'; import { useIsFocused } from '@react-navigation/native'; import { useBalance } from './hooks/useBalance'; import { ListItemRate } from './components/ListItemRate'; import { TonIcon } from '@tonkeeper/uikit'; import { CryptoCurrencies, TabletMaxWidth } from '$shared/constants'; -import { TouchableOpacity } from 'react-native-gesture-handler'; import { useBottomTabBarHeight } from '$hooks/useBottomTabBarHeight'; import { useInternalNotifications } from './hooks/useInternalNotifications'; import { mainActions } from '$store/main'; @@ -47,9 +47,11 @@ import { trackEvent } from '$utils/stats'; import { useTronBalances } from '@tonkeeper/shared/query/hooks/useTronBalances'; import { tk } from '@tonkeeper/shared/tonkeeper'; import { ExpiringDomainCell } from './components/ExpiringDomainCell'; +import { BatteryIcon } from '@tonkeeper/shared/components/BatteryIcon/BatteryIcon'; import { useNetInfo } from '@react-native-community/netinfo'; import { format } from 'date-fns'; import { getLocale } from '$utils/date'; +import { TouchableOpacity } from 'react-native-gesture-handler'; export const WalletScreen = memo(() => { const flags = useFlags(['disable_swap']); @@ -139,7 +141,11 @@ export const WalletScreen = memo(() => { ))} {shouldUpdate && } - + + + + + {wallet && tk.wallet && isConnected !== false ? ( ({ width: TabletMaxWidth, }, }, + balanceWithBattery: { + flexDirection: 'row', + alignItems: 'center', + }, })); diff --git a/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx b/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx index c9558bd41..b25a63d9f 100644 --- a/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx +++ b/packages/mobile/src/tabs/Wallet/components/WalletContentList.tsx @@ -188,7 +188,7 @@ export const WalletContentList = memo( }) => { const theme = useTheme(); const dispatch = useDispatch(); - const usdtRate = useTokenPrice('USDT'); + const fiatCurrency = useSelector(fiatCurrencySelector); const shouldShowTonDiff = fiatCurrency !== FiatCurrencies.Ton; const inscriptions = useTonInscriptions(); diff --git a/packages/mobile/src/uikit/NavBar/NavBar.interface.ts b/packages/mobile/src/uikit/NavBar/NavBar.interface.ts index 3a12c522e..67e007248 100644 --- a/packages/mobile/src/uikit/NavBar/NavBar.interface.ts +++ b/packages/mobile/src/uikit/NavBar/NavBar.interface.ts @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; -import { AnimateProps, SharedValue } from 'react-native-reanimated'; -import { TextProps, ViewStyle } from 'react-native'; +import { SharedValue } from 'react-native-reanimated'; +import { ViewStyle } from 'react-native'; +import { TextProps } from '../Text/Text'; export interface NavBarProps { children: ReactNode; @@ -8,6 +9,7 @@ export interface NavBarProps { isModal?: boolean; title?: string | React.ReactNode; rightContent?: ReactNode; + subtitleProps?: TextProps; hideBackButton?: boolean; hideTitle?: boolean; forceBigTitle?: boolean; @@ -20,7 +22,7 @@ export interface NavBarProps { isCancelButton?: boolean; withBackground?: boolean; scrollTop?: SharedValue; - titleProps?: AnimateProps; + titleProps?: TextProps; fillBackground?: boolean; innerAnimatedStyle?: ViewStyle; } diff --git a/packages/mobile/src/uikit/NavBar/NavBar.style.ts b/packages/mobile/src/uikit/NavBar/NavBar.style.ts index c1a7dab5e..367c55d3c 100644 --- a/packages/mobile/src/uikit/NavBar/NavBar.style.ts +++ b/packages/mobile/src/uikit/NavBar/NavBar.style.ts @@ -3,8 +3,8 @@ import Animated from 'react-native-reanimated'; import styled, { css } from '$styled'; import { ns, hNs } from '$utils'; -import { IsTablet, NavBarHeight, Opacity, TabletMaxWidth } from '$shared/constants'; -import { Text } from '$uikit/Text/Text'; +import { NavBarHeight, Opacity } from '$shared/constants'; +import { Text } from '@tonkeeper/uikit'; export const Wrap = styled.View<{ isTransparent: boolean; isBackground: boolean }>` z-index: 10; @@ -90,14 +90,3 @@ export const CenterContent = styled(Animated.View)` z-index: 1; margin: 0 ${hNs(NavBarHeight - 24)}px; `; - -export const Title = styled(Text).attrs(() => ({ - textAlign: 'center', -}))``; - -export const Subtitle = styled(Text).attrs(() => ({ - textAlign: 'center', -}))` - position: absolute; - width: 100%; -`; diff --git a/packages/mobile/src/uikit/NavBar/NavBar.tsx b/packages/mobile/src/uikit/NavBar/NavBar.tsx index 1dcca8174..0427c97d2 100644 --- a/packages/mobile/src/uikit/NavBar/NavBar.tsx +++ b/packages/mobile/src/uikit/NavBar/NavBar.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { LayoutChangeEvent, View } from 'react-native'; import Animated, { @@ -15,6 +15,7 @@ import { useTheme } from '$hooks/useTheme'; import { NavBarHeight } from '$shared/constants'; import { hNs } from '$utils'; import { Text } from '../Text/Text'; +import { Steezy } from '@tonkeeper/uikit'; export const NavBarHelper: FC = () => { const { top } = useSafeAreaInsets(); @@ -47,6 +48,7 @@ export const NavBar: FC = (props) => { fillBackground = false, innerAnimatedStyle, titleProps = {}, + subtitleProps = {}, scrollTop, subtitle, } = props; @@ -167,26 +169,30 @@ export const NavBar: FC = (props) => { {typeof children === 'string' ? ( - {children} - + ) : ( children )} {subtitle ? ( - {subtitle} - + ) : null} @@ -196,3 +202,10 @@ export const NavBar: FC = (props) => { ); }; + +const styles = Steezy.create({ + subtitle: { + position: 'absolute', + width: '100%', + }, +}); diff --git a/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.interface.ts b/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.interface.ts index 3f4b6e839..cc7f94e7d 100644 --- a/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.interface.ts +++ b/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.interface.ts @@ -13,4 +13,5 @@ export interface ScrollHandlerProps { hitSlop?: Insets; bottomComponent?: ReactNode; titleProps?: TextProps; + subtitleProps?: TextProps; } diff --git a/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.tsx b/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.tsx index f7629b549..9e3d71c3f 100644 --- a/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.tsx +++ b/packages/mobile/src/uikit/ScrollHandler/ScrollHandler.tsx @@ -20,6 +20,7 @@ export const ScrollHandler: FC = (props) => { isLargeNavBar = true, hideBackButton, hitSlop, + subtitleProps = {}, titleProps = {}, } = props; @@ -50,6 +51,7 @@ export const ScrollHandler: FC = (props) => { scrollTop={scrollTop} rightContent={navBarRight} subtitle={navBarSubtitle} + subtitleProps={subtitleProps} > {navBarTitle} diff --git a/packages/shared/components/BatteryIcon/BatteryIcon.tsx b/packages/shared/components/BatteryIcon/BatteryIcon.tsx new file mode 100644 index 000000000..e11a77423 --- /dev/null +++ b/packages/shared/components/BatteryIcon/BatteryIcon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from 'react'; +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'; + +const iconNames: { [key: string]: IconNames } = { + [BatteryState.Empty]: 'ic-empty-battery-28', + [BatteryState.AlmostEmpty]: 'ic-empty-battery-28', + [BatteryState.Medium]: 'ic-almost-empty-battery-28', + [BatteryState.Full]: 'ic-full-battery-28', +}; + +const hitSlop = { top: 8, bottom: 8, right: 8, left: 8 }; + +export const BatteryIcon = memo(() => { + const { balance } = useBatteryBalance(); + if (!balance || balance === '0' || config.get('disable_battery')) return null; + + const iconName = iconNames[getBatteryState(balance)]; + + return ( + + + + ); +}); diff --git a/packages/shared/components/RefillBattery/RechargeByPromoButton.tsx b/packages/shared/components/RefillBattery/RechargeByPromoButton.tsx new file mode 100644 index 000000000..9b039b24c --- /dev/null +++ b/packages/shared/components/RefillBattery/RechargeByPromoButton.tsx @@ -0,0 +1,16 @@ +import { memo } from 'react'; +import { Button, View } from '@tonkeeper/uikit'; +import { t } from '@tonkeeper/shared/i18n'; +import { openRechargeByPromoModal } from '../../modals/ActivityActionModal/RechargeByPromoModal'; + +export const RechargeByPromoButton = memo(() => { + return ( + +