diff --git a/VERSION_CODE b/VERSION_CODE index 6e16ebf9e..274ccca8a 100644 --- a/VERSION_CODE +++ b/VERSION_CODE @@ -1 +1 @@ -208 \ No newline at end of file +209 \ No newline at end of file diff --git a/app/components/Item.tsx b/app/components/Item.tsx index 43c0a2ad7..19dfde8f2 100644 --- a/app/components/Item.tsx +++ b/app/components/Item.tsx @@ -43,7 +43,8 @@ export const ItemSwitch = memo((props: { onChange: (value: boolean) => void, leftIcon?: ImageSourcePropType, leftIconComponent?: any, - titleStyle?: StyleProp + titleStyle?: StyleProp, + style?: StyleProp, disabled?: boolean, }) => { const theme = useTheme(); @@ -61,6 +62,7 @@ export const ItemSwitch = memo((props: { minHeight: 72 }, Platform.select({ android: { opacity: props.disabled ? 0.8 : 1 } }), + props.style ]} disabled={props.disabled} > diff --git a/app/engine/api/fetchJettonPayload.ts b/app/engine/api/fetchJettonPayload.ts new file mode 100644 index 000000000..ea996e020 --- /dev/null +++ b/app/engine/api/fetchJettonPayload.ts @@ -0,0 +1,30 @@ +import axios from "axios"; +import { z } from "zod"; + +const jettonPayloadScheme = z.object({ + customPayload: z.string().optional().nullable(), + stateInit: z.string().optional().nullable() +}); + +export type JettonPayload = z.infer; + +export async function fetchJettonPayload(account: string, jettonMaster: string, customPayloadApiUri?: string | null): Promise { + const endpoint = `https://connect.tonhubapi.com/mintless/jettons/`; + const path = `${jettonMaster}/transfer/${account}/payload`; + + const searchParams = new URLSearchParams(); + if (customPayloadApiUri) { + searchParams.append('customPayloadApiUri', customPayloadApiUri); + } + const search = searchParams.toString(); + const url = `${endpoint}${path}${search ? `?${search}` : ''}`; + const res = (await axios.get(url)).data; + + const parsed = jettonPayloadScheme.safeParse(res); + + if (!parsed.success) { + throw Error('Invalid jetton payload'); + } + + return parsed.data; +} \ No newline at end of file diff --git a/app/engine/api/fetchMintlessHints.ts b/app/engine/api/fetchMintlessHints.ts new file mode 100644 index 000000000..d3a5b0904 --- /dev/null +++ b/app/engine/api/fetchMintlessHints.ts @@ -0,0 +1,48 @@ +import axios from "axios"; +import { z } from "zod"; + +const mintlessJettonScheme = z.object({ + balance: z.string(), + walletAddress: z.object({ + address: z.string(), + name: z.string().optional().nullable(), + icon: z.string().optional().nullable(), + isScam: z.boolean(), + isWallet: z.boolean() + }), + price: z.object({ + prices: z.record(z.number()).optional().nullable(), + diff24h: z.record(z.string()).optional().nullable(), + diff7d: z.record(z.string()).optional().nullable(), + diff30d: z.record(z.string()).optional().nullable() + }).optional().nullable(), + jetton: z.object({ + address: z.string(), + name: z.string(), + symbol: z.string(), + decimals: z.number(), + image: z.string(), + verification: z.string(), + customPayloadApiUri: z.string().nullable().optional() + }), + extensions: z.array(z.string()), + lock: z.object({ + amount: z.string(), + till: z.number() + }).optional().nullable() +}); +const mintlessJettonListScheme = z.array(mintlessJettonScheme); + +export type MintlessJetton = z.infer; + +export async function fetchMintlessHints(address: string): Promise { + let res = (await axios.get(`https://connect.tonhubapi.com/mintless/jettons/${address}`)).data; + + const parsed = mintlessJettonListScheme.safeParse(res); + + if (!parsed.success) { + throw Error('Invalid mintless hints'); + } + + return parsed.data; +} \ No newline at end of file diff --git a/app/engine/hooks/jettons/useHints.ts b/app/engine/hooks/jettons/useHints.ts index a8b2d2353..de5bd5714 100644 --- a/app/engine/hooks/jettons/useHints.ts +++ b/app/engine/hooks/jettons/useHints.ts @@ -3,6 +3,10 @@ import { Queries } from '../../queries'; import { fetchHints } from '../../api/fetchHints'; import { z } from "zod"; import { storagePersistence } from '../../../storage/storage'; +import { fetchMintlessHints, MintlessJetton } from '../../api/fetchMintlessHints'; +import { queryClient } from '../../clients'; +import { StoredJettonWallet } from '../../metadata/StoredMetadata'; +import { getQueryData } from '../../utils/getQueryData'; const txsHintsKey = 'txsHints'; const txsHintsCodec = z.array(z.string()); @@ -44,8 +48,45 @@ export function useHints(addressString?: string): string[] { }, enabled: !!addressString, refetchInterval: 10000, + refetchOnMount: true, refetchOnWindowFocus: true, }); + return hints.data || []; +} + +export function useMintlessHints(addressString?: string): MintlessJetton[] { + let hints = useQuery({ + queryKey: Queries.Mintless(addressString || ''), + queryFn: async () => { + try { + const fetched = await fetchMintlessHints(addressString!); + + const cache = queryClient.getQueryCache(); + // update jetton wallets with mintless hints + fetched?.forEach(hint => { + const wallet = getQueryData(cache, Queries.Account(hint.walletAddress.address).JettonWallet()); + + if (!wallet) { + queryClient.setQueryData(Queries.Account(hint.walletAddress.address).JettonWallet(), { + balance: hint.balance, + owner: addressString!, + master: hint.jetton.address, + address: hint.walletAddress.address + }); + } + }); + + return fetched; + } catch (e) { + console.warn('fetch mintless hints error', e); + } + }, + enabled: !!addressString, + refetchInterval: 10000, + refetchOnMount: true, + refetchOnWindowFocus: true + }); + return hints.data || []; } \ No newline at end of file diff --git a/app/engine/hooks/jettons/useJettonPayload.ts b/app/engine/hooks/jettons/useJettonPayload.ts new file mode 100644 index 000000000..815d93644 --- /dev/null +++ b/app/engine/hooks/jettons/useJettonPayload.ts @@ -0,0 +1,42 @@ +import { useQuery } from "@tanstack/react-query"; +import { Queries } from "../../queries"; +import { fetchJettonPayload } from "../../api/fetchJettonPayload"; +import { queryClient } from "../../clients"; +import { getQueryData } from "../../utils/getQueryData"; +import { MintlessJetton } from "../../api/fetchMintlessHints"; +import { Address } from "@ton/core"; + +export function useJettonPayload(account?: string, masterAddress?: string) { + const enabled = !!account && !!masterAddress; + + const query = useQuery({ + queryKey: Queries.Jettons().Address(account || '').WalletPayload(masterAddress || ''), + queryFn: async () => { + if (!account || !masterAddress) { + return null; + } + + const queryCache = queryClient.getQueryCache(); + const mintlessHints = getQueryData(queryCache, Queries.Mintless(account!)) || []; + const mintlessJetton = mintlessHints.find(h => Address.parse(masterAddress).equals(Address.parse(h.jetton.address)))?.jetton; + + if (!mintlessJetton) { + return null; + } + + const customPayloadApiUri = mintlessJetton.customPayloadApiUri; + const res = await fetchJettonPayload(account!, masterAddress!, customPayloadApiUri); + return res; + }, + enabled, + staleTime: 1000 * 5, + refetchOnMount: true, + refetchOnWindowFocus: true + }); + + return { + data: query.data, + loading: (query.isFetching || query.isLoading) && enabled, + isError: query.isError, + } +} \ No newline at end of file diff --git a/app/engine/hooks/jettons/usePrefetchHints.ts b/app/engine/hooks/jettons/usePrefetchHints.ts index 4dc5452cd..92876be5f 100644 --- a/app/engine/hooks/jettons/usePrefetchHints.ts +++ b/app/engine/hooks/jettons/usePrefetchHints.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useHints } from './useHints'; +import { useHints, useMintlessHints } from './useHints'; import { useNetwork } from '../network/useNetwork'; import { Queries } from '../../queries'; import { fetchMetadata } from '../../metadata/fetchMetadata'; @@ -13,10 +13,13 @@ import { TonClient4 } from '@ton/ton'; import { QueryClient } from '@tanstack/react-query'; import { storage } from '../../../storage/storage'; import { create, keyResolver, windowedFiniteBatchScheduler } from "@yornaath/batshit"; -import { clients } from '../../clients'; +import { clients, queryClient } from '../../clients'; import { AsyncLock } from 'teslabot'; import memoize from '../../../utils/memoize'; import { tryGetJettonWallet } from '../../metadata/introspections/tryGetJettonWallet'; +import { tryFetchJettonWalletIsClaimed } from '../../metadata/introspections/tryFetchJettonWalletIsClaimed'; +import { fetchMintlessHints, MintlessJetton } from '../../api/fetchMintlessHints'; +import { getQueryData } from '../../utils/getQueryData'; let jettonFetchersLock = new AsyncLock(); @@ -110,14 +113,33 @@ const walletBatcher = memoize((client: TonClient4, isTestnet: boolean) => { await Promise.all(wallets.map(async (wallet) => { try { let address = Address.parse(wallet); + log(`[jetton-wallet] 🟑 batch ${wallet}`); let data = await tryFetchJettonWallet(client, await getLastBlock(), address); if (!data) { return; } + let isClaimed = await tryFetchJettonWalletIsClaimed(client, await getLastBlock(), address); + let mintlessBalance; + + if (isClaimed === false) { + const owner = data.owner.toString({ testOnly: isTestnet }); + const queryCache = queryClient.getQueryCache(); + const queryKey = Queries.Mintless(owner); + let mintlessHints = getQueryData(queryCache, queryKey) || []; + + if (!mintlessHints) { + try { + mintlessHints = await fetchMintlessHints(owner); + } catch { } + } + + mintlessBalance = mintlessHints.find(hint => hint.walletAddress.address === wallet)?.balance; + } + result.push({ - balance: data.balance.toString(10), + balance: mintlessBalance || data.balance.toString(10), master: data.master.toString({ testOnly: isTestnet }), owner: data.owner.toString({ testOnly: isTestnet }), address: wallet @@ -218,6 +240,7 @@ function invalidateJettonsDataIfVersionChanged(queryClient: QueryClient) { export function usePrefetchHints(queryClient: QueryClient, address?: string) { const hints = useHints(address); + const mintlessHints = useMintlessHints(address); const { isTestnet } = useNetwork(); useEffect(() => { @@ -280,8 +303,23 @@ export function usePrefetchHints(queryClient: QueryClient, address?: string) { }); } })); + + // Prefetch mintless jettons + await Promise.all(mintlessHints.map(async hint => { + let result = queryClient.getQueryData(Queries.Jettons().MasterContent(hint.jetton.address)); + if (!result) { + await queryClient.prefetchQuery({ + queryKey: Queries.Jettons().MasterContent(hint.jetton.address), + queryFn: jettonMasterContentQueryFn(hint.jetton.address, isTestnet), + }); + await queryClient.prefetchQuery({ + queryKey: Queries.Jettons().Address(address).Wallet(hint.walletAddress.address), + queryFn: jettonWalletAddressQueryFn(hint.walletAddress.address, address, isTestnet) + }); + } + })); })().catch((e) => { console.warn(e); }); - }, [address, hints]); + }, [address, hints, mintlessHints]); } \ No newline at end of file diff --git a/app/engine/hooks/jettons/useSortedHintsWatcher.ts b/app/engine/hooks/jettons/useSortedHintsWatcher.ts index 0fd99bd27..13b7c7030 100644 --- a/app/engine/hooks/jettons/useSortedHintsWatcher.ts +++ b/app/engine/hooks/jettons/useSortedHintsWatcher.ts @@ -1,12 +1,13 @@ import { useCallback, useEffect } from "react"; import { getSortedHints, useSortedHintsState } from "./useSortedHints"; import { useNetwork } from ".."; -import { compareHints, filterHint, getHint } from "../../../utils/hintSortFilter"; +import { compareHints, filterHint, getHint, getMintlessHint } from "../../../utils/hintSortFilter"; import { queryClient } from "../../clients"; import { QueryCacheNotifyEvent } from "@tanstack/react-query"; import { Queries } from "../../queries"; import { getQueryData } from "../../utils/getQueryData"; import { throttle } from "../../../utils/throttle"; +import { MintlessJetton } from "../../api/fetchMintlessHints"; // check if two arrays are equal by content invariant of the order function areArraysEqualByContent(a: T[], b: T[]): boolean { @@ -77,6 +78,14 @@ function useSubToHintChange( }, [owner, reSortHints]); } +enum HintType { + Hint = 'hint', + Mintless = 'mintless' +} + +type SortableHint = { hintType: HintType.Hint, address: string } + | { hintType: HintType.Mintless, address: string, hint: MintlessJetton }; + export function useSortedHintsWatcher(address?: string) { const { isTestnet } = useNetwork(); const [, setSortedHints] = useSortedHintsState(address); @@ -84,12 +93,33 @@ export function useSortedHintsWatcher(address?: string) { const resyncAllHintsWeights = useCallback(throttle(() => { const cache = queryClient.getQueryCache(); const hints = getQueryData(cache, Queries.Hints(address ?? '')); - if (!hints) { - return; - } + const mintlessHints = getQueryData(cache, Queries.Mintless(address ?? '')); + + const allHints = [ + ...(hints || []).map((h) => ({ hintType: HintType.Hint, address: h })), + ...(mintlessHints || []).map((h) => ({ hintType: HintType.Mintless, address: h.walletAddress.address, hint: h })) + ] + + const allHintsSet = new Set([...hints ?? [], ...mintlessHints?.map((h) => h.walletAddress.address) ?? []]); + + const noDups: SortableHint[] = Array.from(allHintsSet).map((a) => { + const hint = allHints.find((h) => h.address === a); + + if (!hint) { + return null; + } + + return hint; + }).filter((x) => !!x) as SortableHint[]; + + const sorted = noDups + .map((h) => { + if (h.hintType === HintType.Hint) { + return getHint(cache, h.address, isTestnet); + } - const sorted = hints - .map((h) => getHint(cache, h, isTestnet)) + return getMintlessHint(cache, h.hint, isTestnet); + }) .sort(compareHints).filter(filterHint([])).map((x) => x.address); setSortedHints(sorted); diff --git a/app/engine/hooks/metadata/useContractMetadatas.ts b/app/engine/hooks/metadata/useContractMetadatas.ts index 986ecfbde..698058f71 100644 --- a/app/engine/hooks/metadata/useContractMetadatas.ts +++ b/app/engine/hooks/metadata/useContractMetadatas.ts @@ -2,11 +2,9 @@ import { useQueries } from '@tanstack/react-query'; import { Queries } from '../../queries'; import { contractMetadataQueryFn } from '../jettons/usePrefetchHints'; import { useNetwork } from '../network/useNetwork'; -import { useClient4 } from '../network/useClient4'; export function useContractMetadatas(contracts: string[]) { const { isTestnet } = useNetwork(); - const client = useClient4(isTestnet); return useQueries({ queries: contracts.map(m => ({ diff --git a/app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.spec.ts b/app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.spec.ts new file mode 100644 index 000000000..51a1a4cad --- /dev/null +++ b/app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.spec.ts @@ -0,0 +1,24 @@ +import { Address } from "@ton/core"; +import { tryFetchJettonWalletIsClaimed } from "./tryFetchJettonWalletIsClaimed"; +import { TonClient4 } from "@ton/ton"; + +const client = new TonClient4({ endpoint: 'https://mainnet-v4.tonhubapi.com', timeout: 5000, }); + +describe('tryFetchJettonWalletIsClaimed', () => { + it('should fetch is claimed: true', async () => { + let res = (await tryFetchJettonWalletIsClaimed(client, 40498629, Address.parse('EQAYPGheJ6Pbv85zf1nWzXRaZhSN2aWw7pSsrJtBOE6RJxvt')))!; + expect(res).not.toBeNull(); + expect(res).toBe(true); + }); + + it('should fetch is claimed: false', async () => { + let res = (await tryFetchJettonWalletIsClaimed(client, 40498629, Address.parse('EQCRuNW8buZSMivhTNNdFIhyoR7ecX-Uao5xgZAABWlW7ho1')))!; + expect(res).not.toBeNull(); + expect(res).toBe(false); + }); + + it('should fetch is claimed: null', async () => { + let res = (await tryFetchJettonWalletIsClaimed(client, 40498629, Address.parse('UQCZXhk9pNjZvFohplQXrQG3NDgYKya4C3QwASqXnEJ4TNqd')))!; + expect(res).toBeNull(); + }); +}); \ No newline at end of file diff --git a/app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.ts b/app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.ts new file mode 100644 index 000000000..dde63f940 --- /dev/null +++ b/app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.ts @@ -0,0 +1,15 @@ +import { Address } from "@ton/core"; +import { TonClient4 } from '@ton/ton'; + +export async function tryFetchJettonWalletIsClaimed(client: TonClient4, seqno: number, address: Address) { + let result = await client.runMethod(seqno, address, 'is_claimed'); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return null; + } + if (result.result[0].type !== 'int') { + return null; + } + + return result.result[0].value === 1n; +} \ No newline at end of file diff --git a/app/engine/queries.ts b/app/engine/queries.ts index 17735478d..cf4f527bb 100644 --- a/app/engine/queries.ts +++ b/app/engine/queries.ts @@ -34,6 +34,7 @@ export const Queries = { AppVersionsConfig: (network: 'testnet' | 'mainnet') => ['appVersionsConfig', network], Hints: (address: string) => (['hints', address]), + Mintless: (address: string) => (['mintless', address]), Cloud: (address: string) => ({ Key: (key: string) => ['cloud', address, key] }), @@ -42,6 +43,7 @@ export const Queries = { Address: (address: string) => ({ AllWallets: () => ['jettons', 'address', address, 'master'], Wallet: (masterAddress: string) => ['jettons', 'address', address, 'master', masterAddress], + WalletPayload: (walletAddress: string) => ['jettons', 'address', address, 'wallet', walletAddress, 'payload'], }), Swap: (masterAddress: string) => ['jettons', 'swap', masterAddress], Known: () => ['jettons', 'known'], diff --git a/app/fragments/secure/SimpleTransferFragment.tsx b/app/fragments/secure/SimpleTransferFragment.tsx index b913f8b33..5570055c7 100644 --- a/app/fragments/secure/SimpleTransferFragment.tsx +++ b/app/fragments/secure/SimpleTransferFragment.tsx @@ -43,6 +43,7 @@ import { useWalletVersion } from '../../engine/hooks/useWalletVersion'; import { WalletContractV4, WalletContractV5R1 } from '@ton/ton'; import { WalletVersions } from '../../engine/types'; import { useGaslessConfig } from '../../engine/hooks/jettons/useGaslessConfig'; +import { useJettonPayload } from '../../engine/hooks/jettons/useJettonPayload'; import IcTonIcon from '@assets/ic-ton-acc.svg'; import IcChevron from '@assets/ic_chevron_forward.svg'; @@ -91,10 +92,10 @@ export const SimpleTransferFragment = fragment(() => { } catch { } } }, [addr]); + const address = isLedger ? ledgerAddress : acc!.address; - const txs = useAccountTransactions((ledgerAddress ?? acc!.address).toString({ testOnly: network.isTestnet })).data; - - const accountLite = useAccountLite(isLedger ? ledgerAddress : acc!.address); + const txs = useAccountTransactions(address!.toString({ testOnly: network.isTestnet })).data; + const accountLite = useAccountLite(address); const [addressDomainInputState, setAddressDomainInputState] = useState( { @@ -124,7 +125,8 @@ export const SimpleTransferFragment = fragment(() => { const hasGaslessTransfer = gaslessConfig?.data?.gas_jettons.map((j) => { return Address.parse(j.master_id); }).some((j) => jettonWallet?.master && j.equals(Address.parse(jettonWallet.master))); - const symbol = jetton ? jetton.symbol : 'TON' + const symbol = jetton ? jetton.symbol : 'TON'; + const { data: jettonPayload, loading: isJettonPayloadLoading } = useJettonPayload(address?.toString({ testOnly: network.isTestnet }), jettonWallet?.master); const targetAddressValid = useMemo(() => { if (target.length > 48) { @@ -315,7 +317,17 @@ export const SimpleTransferFragment = fragment(() => { // Resolve jetton order if (jettonState) { - const txAmount = feeAmount ?? (toNano('0.05') + estim); + const customPayload = jettonPayload?.customPayload ?? null; + const customPayloadCell = customPayload ? Cell.fromBoc(Buffer.from(customPayload, 'base64'))[0] : null; + const stateInit = jettonPayload?.stateInit ?? null; + const stateInitCell = stateInit ? Cell.fromBoc(Buffer.from(stateInit, 'base64'))[0] : null; + + let txAmount = feeAmount ?? (toNano('0.05') + estim); + + if (!!stateInit || !!customPayload) { + txAmount = feeAmount ?? (toNano('0.1') + estim); + } + const tonAmount = forwardAmount ?? 1n; return createJettonOrder({ @@ -327,7 +339,9 @@ export const SimpleTransferFragment = fragment(() => { amount: validAmount, tonAmount, txAmount, - payload: payload + customPayload: customPayloadCell, + payload: payload, + stateInit: stateInitCell }, network.isTestnet); } @@ -343,7 +357,7 @@ export const SimpleTransferFragment = fragment(() => { app: params?.app }); - }, [validAmount, target, domain, commentString, stateInit, jettonState, params?.app, acc, ledgerAddress, known]); + }, [validAmount, target, domain, commentString, stateInit, jettonState, params?.app, acc, ledgerAddress, known, jettonPayload]); const walletVersion = useWalletVersion(); @@ -488,7 +502,7 @@ export const SimpleTransferFragment = fragment(() => { return () => { ended = true; } - }, [order, accountLite, client, config, commentString, ledgerAddress, walletVersion, hasGaslessTransfer]); + }, [order, accountLite, client, config, commentString, ledgerAddress, walletVersion, hasGaslessTransfer, jettonPayload?.customPayload, jettonPayload?.stateInit]); const linkNavigator = useLinkNavigator(network.isTestnet); const onQRCodeRead = useCallback((src: string) => { @@ -546,7 +560,7 @@ export const SimpleTransferFragment = fragment(() => { }); } } - }, [commentString, target, validAmount, stateInit, selectedJetton,]); + }, [commentString, target, validAmount, stateInit, selectedJetton]); const onAddAll = useCallback(() => { const amount = jettonState @@ -838,6 +852,9 @@ export const SimpleTransferFragment = fragment(() => { })); }); + const continueDisabled = !order || gaslessConfigLoading || isJettonPayloadLoading; + const continueLoading = gaslessConfigLoading || isJettonPayloadLoading; + return ( @@ -991,11 +1008,7 @@ export const SimpleTransferFragment = fragment(() => { marginBottom: 12, justifyContent: 'space-between' }}> - + {`${t('common.balance')}: `} { style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })} onPress={onAddAll} > - + {t('transfer.sendAll')} @@ -1045,13 +1054,7 @@ export const SimpleTransferFragment = fragment(() => { /> {amountError && ( - + {amountError} @@ -1190,8 +1193,8 @@ export const SimpleTransferFragment = fragment(() => { onPress={onNext ? onNext : undefined} /> : diff --git a/app/fragments/secure/TransferFragment.tsx b/app/fragments/secure/TransferFragment.tsx index 2f76d84ef..6b6d2d719 100644 --- a/app/fragments/secure/TransferFragment.tsx +++ b/app/fragments/secure/TransferFragment.tsx @@ -42,6 +42,7 @@ import { Queries } from '../../engine/queries'; import { JettonMasterState } from '../../engine/metadata/fetchJettonMasterContent'; import { toBnWithDecimals } from '../../utils/withDecimals'; import { updateTargetAmount } from '../../utils/gasless/updateTargetAmount'; +import { MintlessJetton } from '../../engine/api/fetchMintlessHints'; export type TransferRequestSource = { type: 'tonconnect', returnStrategy?: ReturnStrategy | null } @@ -278,7 +279,7 @@ export const TransferFragment = fragment(() => { const emptySecret = Buffer.alloc(64); - let block = await backoff('txLoad-blc', () => client.getLastBlock()); + const block = await backoff('txLoad-blc', () => client.getLastBlock()); // // Single transfer @@ -326,6 +327,21 @@ export const TransferFragment = fragment(() => { let jettonTargetState: typeof state | null = null; let jettonTarget: typeof target | null = null; + // check if its a mintless jetton + const queryCache = queryClient.getQueryCache(); + const address = selectedAccount!.address.toString({ testOnly: isTestnet }) || ''; + const mintlessJettons = getQueryData(queryCache, Queries.Mintless(address)); + const mintlessJetton = mintlessJettons?.find(j => Address.parse(j.walletAddress.address).equals(target.address)); + + if (!!mintlessJetton) { + metadata.jettonWallet = { + balance: BigInt(mintlessJetton.balance), + owner: selectedAccount!.address, + master: Address.parse(mintlessJetton.jetton.address), + address: target.address + }; + } + // Read jetton master if (metadata.jettonWallet) { let body = order.messages[0].payload ? parseBody(order.messages[0].payload) : null; @@ -348,10 +364,12 @@ export const TransferFragment = fragment(() => { forwardPayload = sc.loadMaybeRef() ?? sc.asCell(); } + const destination = Address.parseFriendly(jettonTargetAddress.toString({ testOnly: isTestnet, bounceable: bounceableFormat })); + jettonTransfer = { queryId, amount: jettonAmount, - destination: Address.parseFriendly(jettonTargetAddress.toString({ testOnly: isTestnet, bounceable: bounceableFormat })), + destination, responseDestination, customPayload, forwardTonAmount, @@ -371,7 +389,7 @@ export const TransferFragment = fragment(() => { } if (jettonTarget) { - jettonTargetState = await backoff('txLoad-jts', () => client.getAccount(block.last.seqno, target.address)); + jettonTargetState = await backoff('txLoad-jts', () => client.getAccount(block.last.seqno, jettonTarget.address)); } if (order.domain) { diff --git a/app/fragments/secure/components/TransferSingle.tsx b/app/fragments/secure/components/TransferSingle.tsx index 1f61768c1..408519474 100644 --- a/app/fragments/secure/components/TransferSingle.tsx +++ b/app/fragments/secure/components/TransferSingle.tsx @@ -44,7 +44,11 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { const [walletSettings] = useWalletSettings(selected?.address); const [failed, setFailed] = useState(false); - const jetton = useJetton({ owner: selected!.address, master: metadata?.jettonWallet?.master, wallet: metadata?.jettonWallet?.address }, true); + const jetton = useJetton({ + owner: selected!.address, + master: metadata?.jettonWallet?.master, + wallet: metadata?.jettonWallet?.address + }, true); // Resolve operation let body = order.messages[0].payload ? parseBody(order.messages[0].payload) : null; @@ -474,6 +478,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { contact={contact} failed={failed} isGasless={fees.type === 'gasless' && fees.params.ok} + onSetUseGasless={onSetUseGasless} /> ); }); \ No newline at end of file diff --git a/app/fragments/secure/components/TransferSingleView.tsx b/app/fragments/secure/components/TransferSingleView.tsx index 14db409e5..dd457c80e 100644 --- a/app/fragments/secure/components/TransferSingleView.tsx +++ b/app/fragments/secure/components/TransferSingleView.tsx @@ -33,6 +33,7 @@ import { ThemeType } from "../../../engine/state/theme"; import { ForcedAvatar, ForcedAvatarType } from "../../../components/avatar/ForcedAvatar"; import { HoldersOp, HoldersOpView } from "../../../components/transfer/HoldersOpView"; import { TransferEstimate } from "../TransferFragment"; +import { ItemSwitch } from "../../../components/Item"; import WithStateInit from '@assets/ic_sign_contract.svg'; import IcAlert from '@assets/ic-alert.svg'; @@ -109,7 +110,8 @@ export const TransferSingleView = memo(({ isLedger, contact, failed, - isGasless + isGasless, + onSetUseGasless }: { operation: StoredOperation, order: Order | LedgerOrder, @@ -135,7 +137,8 @@ export const TransferSingleView = memo(({ isLedger?: boolean, contact?: AddressContact | null, failed: boolean, - isGasless?: boolean + isGasless?: boolean, + onSetUseGasless?: (useGasless: boolean) => void }) => { const toaster = useToaster(); const navigation = useTypedNavigation(); @@ -384,18 +387,10 @@ export const TransferSingleView = memo(({ - + {t('common.send')} - + - + {t('common.from')} - + {!!from.name && ( @@ -500,10 +488,7 @@ export const TransferSingleView = memo(({ {to.address.toString({ testOnly: isTestnet, bounceable: target.bounceable }).replaceAll('-', '\u2011')} @@ -539,10 +524,7 @@ export const TransferSingleView = memo(({ <> - + {t('transfer.smartContract')} @@ -583,10 +565,7 @@ export const TransferSingleView = memo(({ <> - + {t('transfer.smartContract')} @@ -652,7 +631,15 @@ export const TransferSingleView = memo(({ - + {!!onSetUseGasless && ( + + )} {(amount > toNano('0.2') && !isGasless) && ( { const appState = useAppState(); const addressBook = useAddressBookContext(); const [price, currency] = usePrice(); - const [spamMinAmount,] = useSpamMinAmount(); - const [dontShowComments,] = useDontShowComments(); - const [bounceableFormat,] = useBounceableWalletFormat(); + const [spamMinAmount] = useSpamMinAmount(); + const [dontShowComments] = useDontShowComments(); + const [bounceableFormat] = useBounceableWalletFormat(); const knownJettonMasters = useKnownJettons(isTestnet)?.masters ?? {}; const isLedger = route.name === 'LedgerTransactionPreview'; @@ -93,7 +92,7 @@ const TransactionPreview = () => { }, [tx, isTestnet, bounceableFormat, isLedger]); const preparedMessages = usePeparedMessages(messages, isTestnet); - const [walletsSettings,] = useWalletsSettings(); + const [walletsSettings] = useWalletsSettings(); const ownWalletSettings = walletsSettings[opAddressBounceable]; const opAddressWalletSettings = walletsSettings[opAddressBounceable]; @@ -120,7 +119,11 @@ const TransactionPreview = () => { ); }, [price, currency, fees]); - const jetton = useJettonMaster(tx.metadata?.jettonWallet?.master?.toString({ testOnly: isTestnet }) ?? null); + const resolvedAddressString = tx.base.parsed.resolvedAddress; + const jetton = useJettonWallet(resolvedAddressString); + const metadataMaster = tx.metadata?.jettonWallet?.master?.toString({ testOnly: isTestnet }); + const jettonMasterString = metadataMaster ?? jetton?.master ?? null; + const jettonMasterContent = useJettonMaster(jettonMasterString); const targetContract = useContractInfo(opAddress); let op: string; @@ -246,10 +249,11 @@ const TransactionPreview = () => { }); }, []); + const decimals = (item.kind === 'token' && jettonMasterContent) ? jettonMasterContent.decimals : undefined; const amountText = valueText({ value: item.amount, precision: 9, - decimals: item.kind === 'token' && jetton ? jetton.decimals : undefined, + decimals }); const amountColor = kind === 'in' @@ -258,13 +262,14 @@ const TransactionPreview = () => { : theme.accentGreen : theme.textPrimary - const jettonMaster = tx.masterAddressStr ?? tx.metadata?.jettonWallet?.master?.toString({ testOnly: isTestnet }); - - const { isSCAM: isSCAMJetton, verified: verifiedJetton } = useVerifyJetton({ - ticker: item.kind === 'token' ? jetton?.symbol : undefined, - master: jettonMaster + const { isSCAM: isSCAMJetton } = useVerifyJetton({ + ticker: item.kind === 'token' ? jettonMasterContent?.symbol : undefined, + master: jettonMasterString }); + const symbolString = item.kind === 'ton' ? ' TON' : (jettonMasterContent?.symbol ? ` ${jettonMasterContent.symbol}` : '') + const singleAmountString = `${amountText[0]}${amountText[1]}${symbolString}`; + return ( { numberOfLines={1} style={[{ color: amountColor }, Typography.semiBold27_32]} > - { - `${amountText[0]}${amountText[1]}${item.kind === 'ton' - ? ' TON' - : (jetton?.symbol ? ' ' + jetton?.symbol : '')}` - } + {singleAmountString} {isSCAMJetton && (' β€’ ')} {isSCAMJetton && ( @@ -514,7 +515,7 @@ const TransactionPreview = () => { {tx.base.fees ? <> - {`${formatAmount(fromNano(fees))}`} + {`${formatAmount(fromNano(fees))} TON`} {` ${feesPrise}`} @@ -591,7 +592,7 @@ const TransactionPreview = () => { {tx.base.fees ? <> - {`${formatAmount(fromNano(fees))}`} + {`${formatAmount(fromNano(fees))} TON`} {` ${feesPrise}`} diff --git a/app/fragments/wallet/views/TransactionView.tsx b/app/fragments/wallet/views/TransactionView.tsx index c07896184..38890b7a4 100644 --- a/app/fragments/wallet/views/TransactionView.tsx +++ b/app/fragments/wallet/views/TransactionView.tsx @@ -20,7 +20,7 @@ import { Typography } from '../../../components/styles'; import { avatarHash } from '../../../utils/avatarHash'; import { WalletSettings } from '../../../engine/state/walletSettings'; import { getLiquidStakingAddress } from '../../../utils/KnownPools'; -import { usePeparedMessages, useVerifyJetton } from '../../../engine/hooks'; +import { useJettonMaster, useJettonWallet, usePeparedMessages, useVerifyJetton } from '../../../engine/hooks'; import { TxAvatar } from './TxAvatar'; import { PreparedMessageView } from './PreparedMessageView'; import { useContractInfo } from '../../../engine/hooks/metadata/useContractInfo'; @@ -181,16 +181,18 @@ export function TransactionView(props: { ? (spam ? theme.textPrimary : theme.accentGreen) : theme.textPrimary; - const jettonMaster = tx.masterAddressStr ?? tx.metadata?.jettonWallet?.master?.toString({ testOnly: isTestnet }); + const resolvedAddressString = tx.base.parsed.resolvedAddress; + const jetton = useJettonWallet(resolvedAddressString); + const metadataMaster = tx.metadata?.jettonWallet?.master?.toString({ testOnly: isTestnet }); + const jettonMasterString = tx.masterAddressStr ?? metadataMaster ?? jetton?.master ?? null; + const jettonMasterContent = useJettonMaster(jettonMasterString); const { isSCAM: isSCAMJetton } = useVerifyJetton({ ticker: item.kind === 'token' ? tx.masterMetadata?.symbol : undefined, - master: jettonMaster + master: jettonMasterString }); - const symbolText = `${(item.kind === 'token') - ? `${tx.masterMetadata?.symbol ? ` ${tx.masterMetadata?.symbol}` : ''}` - : ' TON'}${isSCAMJetton ? ' β€’ ' : ''}`; + const symbolText = item.kind === 'ton' ? ' TON' : (jettonMasterContent?.symbol ? ` ${jettonMasterContent.symbol}${isSCAMJetton ? ' β€’ ' : ''}` : '') return ( = { notEnoughJettonsTitle: 'Not enough jettons', notEnoughJettonsMessage: 'You are trying to send more jettons than you have', aboutFees: 'About fees', - aboutFeesDescription: 'The fees for transactions on the blockchain depend on several factors, such as network congestion, transaction size, gas price, and blockchain configuration parameters. The higher the demand for transaction processing on the blockchain or the larger the transaction size (message/comment), the higher the fees will be.' + aboutFeesDescription: 'The fees for transactions on the blockchain depend on several factors, such as network congestion, transaction size, gas price, and blockchain configuration parameters. The higher the demand for transaction processing on the blockchain or the larger the transaction size (message/comment), the higher the fees will be.', + gaslessTransferSwitch: 'Pay gas fee in {{symbol}}' }, auth: { phoneVerify: 'Verify phone', diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index 376b85281..617746a68 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -217,7 +217,8 @@ const schema: PrepareSchema = { "notEnoughJettonsTitle": "НСдостаточно ΠΆΠ΅Ρ‚ΠΎΠ½ΠΎΠ²", "notEnoughJettonsMessage": "Π£ вас нСдостаточно ΠΆΠ΅Ρ‚ΠΎΠ½ΠΎΠ² для ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΈ", "aboutFees": "О комиссиях", - "aboutFeesDescription": "Комиссии Π·Π° Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ Π² Π±Π»ΠΎΠΊΡ‡Π΅ΠΉΠ½Π΅ зависят ΠΎΡ‚ Π½Π΅ΡΠΊΠΎΠ»ΡŒΠΊΠΈΡ… Ρ„Π°ΠΊΡ‚ΠΎΡ€ΠΎΠ²: загруТСнности сСти, Ρ€Π°Π·ΠΌΠ΅Ρ€Π° Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ, Ρ†Π΅Π½Ρ‹ Π³Π°Π·Π° ΠΈ ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Ρ… ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ΠΎΠ² Π±Π»ΠΎΠΊΡ‡Π΅ΠΉΠ½Π°. Π§Π΅ΠΌ большС Ρ€Π°Π·ΠΌΠ΅Ρ€ Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ (сообщСниС/ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ) ΠΈΠ»ΠΈ Π²Ρ‹ΡˆΠ΅ спрос Π½Π° ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ, Ρ‚Π΅ΠΌ Π²Ρ‹ΡˆΠ΅ Π±ΡƒΠ΄ΡƒΡ‚ комиссии." + "aboutFeesDescription": "Комиссии Π·Π° Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ Π² Π±Π»ΠΎΠΊΡ‡Π΅ΠΉΠ½Π΅ зависят ΠΎΡ‚ Π½Π΅ΡΠΊΠΎΠ»ΡŒΠΊΠΈΡ… Ρ„Π°ΠΊΡ‚ΠΎΡ€ΠΎΠ²: загруТСнности сСти, Ρ€Π°Π·ΠΌΠ΅Ρ€Π° Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ, Ρ†Π΅Π½Ρ‹ Π³Π°Π·Π° ΠΈ ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Ρ… ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ΠΎΠ² Π±Π»ΠΎΠΊΡ‡Π΅ΠΉΠ½Π°. Π§Π΅ΠΌ большС Ρ€Π°Π·ΠΌΠ΅Ρ€ Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ (сообщСниС/ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ) ΠΈΠ»ΠΈ Π²Ρ‹ΡˆΠ΅ спрос Π½Π° ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ, Ρ‚Π΅ΠΌ Π²Ρ‹ΡˆΠ΅ Π±ΡƒΠ΄ΡƒΡ‚ комиссии.", + "gaslessTransferSwitch": "ΠžΠΏΠ»Π°Ρ‚ΠΈΡ‚ΡŒ комиссию Π² {{symbol}}" }, "auth": { "phoneNumber": "НомСр Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½Π°", diff --git a/app/i18n/schema.ts b/app/i18n/schema.ts index c1d31dc82..9ed2c73e9 100644 --- a/app/i18n/schema.ts +++ b/app/i18n/schema.ts @@ -236,6 +236,7 @@ export type LocalizationSchema = { notEnoughJettonsMessage: string, aboutFees: string, aboutFeesDescription: string, + gaslessTransferSwitch: string }, auth: { phoneVerify: string, diff --git a/app/utils/hintSortFilter.ts b/app/utils/hintSortFilter.ts index 50bd585b1..ab0bb97d0 100644 --- a/app/utils/hintSortFilter.ts +++ b/app/utils/hintSortFilter.ts @@ -7,6 +7,7 @@ import { JettonMasterState } from "../engine/metadata/fetchJettonMasterContent"; import { getQueryData } from "../engine/utils/getQueryData"; import { QueryCache } from "@tanstack/react-query"; import { jettonMasterContentQueryFn, jettonWalletQueryFn } from "../engine/hooks/jettons/usePrefetchHints"; +import { MintlessJetton } from "../engine/api/fetchMintlessHints"; type Hint = { address: string, @@ -89,6 +90,40 @@ export function getHint(queryCache: QueryCache, hint: string, isTestnet: boolean } } +export function getMintlessHint(queryCache: QueryCache, hint: MintlessJetton, isTestnet: boolean): Hint { + try { + const masterStr = hint.jetton.address; + const masterContent = getQueryData(queryCache, Queries.Jettons().MasterContent(masterStr)); + const swap = getQueryData(queryCache, Queries.Jettons().Swap(masterStr)); + + const { verified, isSCAM } = verifyJetton({ ticker: masterContent?.symbol, master: masterStr }, isTestnet); + + if (!masterContent) { + // prefetch master content + queryClient.prefetchQuery({ + queryKey: Queries.Jettons().MasterContent(masterStr), + queryFn: jettonMasterContentQueryFn(masterStr, isTestnet) + }); + return { address: hint.walletAddress.address }; + } + + return { address: hint.walletAddress.address, swap, verified, isSCAM, balance: BigInt(hint.balance), loaded: true }; + } catch { + return { address: hint.jetton.address }; + } +} + +export function isMintlessJetton(queryCache: QueryCache, address: string) { + const mintlessHints = getQueryData(queryCache, Queries.Mintless(address)); + return mintlessHints?.some(hint => { + try { + return Address.parse(hint.walletAddress.address).equals(Address.parse(address)); + } catch { + return false; + } + }) ?? false; +} + export function compareHints(a: Hint, b: Hint): number { if (!a && !b) { return 0; @@ -102,8 +137,8 @@ export function compareHints(a: Hint, b: Hint): number { return -1; } - let weightA = a.verified ? 2 : 0; - let weightB = b.verified ? 2 : 0; + let weightA = a.verified ? 1 : 0; + let weightB = b.verified ? 1 : 0; if (a.isSCAM) { weightA -= 1; diff --git a/ios/wallet/Info.plist b/ios/wallet/Info.plist index 82df6798b..4e7713343 100644 --- a/ios/wallet/Info.plist +++ b/ios/wallet/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.3.14 + 2.3.15 CFBundleSignature ???? CFBundleURLTypes @@ -41,7 +41,7 @@ CFBundleVersion - 208 + 209 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/package.json b/package.json index 209e38d74..918055f28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wallet", - "version": "2.3.14", + "version": "2.3.15", "scripts": { "start": "expo start --dev-client", "android": "expo run:android",