From 041201e622dae010cfa98c108e22d0111cc4fbfc Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Tue, 17 Sep 2024 21:05:30 +0200 Subject: [PATCH 01/12] wip: adding mintless support --- app/engine/api/fetchJettonPayload.ts | 30 ++++++++++ app/engine/api/fetchMintlessHints.ts | 48 ++++++++++++++++ app/engine/hooks/jettons/useHints.ts | 36 ++++++++++++ app/engine/hooks/jettons/useJettonPayload.ts | 40 ++++++++++++++ app/engine/hooks/jettons/usePrefetchHints.ts | 16 +++++- .../hooks/jettons/useSortedHintsWatcher.ts | 27 +++++++-- .../hooks/metadata/useContractMetadatas.ts | 2 - app/engine/queries.ts | 2 + .../secure/SimpleTransferFragment.tsx | 55 ++++++++++--------- app/fragments/secure/TransferFragment.tsx | 7 ++- .../secure/components/TransferSingle.tsx | 17 +++--- app/fragments/secure/ops/Order.ts | 11 ++-- app/utils/hintSortFilter.ts | 39 ++++++++++++- 13 files changed, 277 insertions(+), 53 deletions(-) create mode 100644 app/engine/api/fetchJettonPayload.ts create mode 100644 app/engine/api/fetchMintlessHints.ts create mode 100644 app/engine/hooks/jettons/useJettonPayload.ts 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..36a4b9089 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,40 @@ 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!); + + // update jetton wallets with mintless hints + fetched?.forEach(hint => { + 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..48f3bd5cb --- /dev/null +++ b/app/engine/hooks/jettons/useJettonPayload.ts @@ -0,0 +1,40 @@ +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 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: !!account && !!masterAddress, + staleTime: 1000 * 5, + refetchOnMount: true, + refetchOnWindowFocus: true + }); + + return { + data: query.data, + loading: query.isFetching || query.isLoading, + 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..eddcde997 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'; @@ -218,6 +218,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 +281,19 @@ 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), + }); + } + })); })().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..455f036fd 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,11 @@ function useSubToHintChange( }, [owner, reSortHints]); } +enum HintType { + Hint = 'hint', + Mintless = 'mintless' +} + export function useSortedHintsWatcher(address?: string) { const { isTestnet } = useNetwork(); const [, setSortedHints] = useSortedHintsState(address); @@ -84,12 +90,21 @@ 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 as HintType.Hint, address: h })), + ...(mintlessHints || []).map((h) => ({ hintType: HintType.Mintless as HintType.Mintless, hint: h })) + ] + + const sorted = allHints + .map((h) => { + if (h.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/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 66465f296..c9b30408d 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'; @@ -90,10 +91,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( { @@ -123,7 +124,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) { @@ -314,7 +316,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({ @@ -326,7 +338,9 @@ export const SimpleTransferFragment = fragment(() => { amount: validAmount, tonAmount, txAmount, - payload: payload + customPayload: customPayloadCell, + payload: payload, + stateInit: stateInitCell }, network.isTestnet); } @@ -342,7 +356,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(); @@ -487,7 +501,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) => { @@ -545,7 +559,7 @@ export const SimpleTransferFragment = fragment(() => { }); } } - }, [commentString, target, validAmount, stateInit, selectedJetton,]); + }, [commentString, target, validAmount, stateInit, selectedJetton]); const onAddAll = useCallback(() => { const amount = jettonState @@ -990,11 +1004,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')} @@ -1044,13 +1050,7 @@ export const SimpleTransferFragment = fragment(() => { /> {amountError && ( - + {amountError} @@ -1189,8 +1189,9 @@ export const SimpleTransferFragment = fragment(() => { onPress={onNext ? onNext : undefined} /> : } diff --git a/app/fragments/secure/TransferFragment.tsx b/app/fragments/secure/TransferFragment.tsx index ba4a363c9..0f8dccf0e 100644 --- a/app/fragments/secure/TransferFragment.tsx +++ b/app/fragments/secure/TransferFragment.tsx @@ -301,6 +301,7 @@ export const TransferFragment = fragment(() => { }; responseDestination: Address | null; customPayload: Cell | null; + stateInit: Cell | null; forwardTonAmount: bigint; forwardPayload: Cell | null; jettonWallet: Address; @@ -338,7 +339,8 @@ export const TransferFragment = fragment(() => { customPayload, forwardTonAmount, forwardPayload, - jettonWallet: metadata.jettonWallet.address + jettonWallet: metadata.jettonWallet.address, + stateInit: order.messages[0].stateInit } if (jettonTargetAddress) { @@ -505,7 +507,8 @@ export const TransferFragment = fragment(() => { to: jettonTransfer.jettonWallet, bounce: true, value: toNano('0.05') + tonEstimate, - body: tetherTransferPayload + body: tetherTransferPayload, + init: jettonTransfer.stateInit ? loadStateInit(jettonTransfer.stateInit.asSlice()) : null }) ) ) diff --git a/app/fragments/secure/components/TransferSingle.tsx b/app/fragments/secure/components/TransferSingle.tsx index 4f0fdc1bf..86f6eab65 100644 --- a/app/fragments/secure/components/TransferSingle.tsx +++ b/app/fragments/secure/components/TransferSingle.tsx @@ -27,8 +27,10 @@ import { clearLastReturnStrategy } from "../../../engine/tonconnect/utils"; import { useWalletVersion } from "../../../engine/hooks/useWalletVersion"; import { WalletContractV4, WalletContractV5R1 } from "@ton/ton"; import { fetchGaslessSend, GaslessSendError } from "../../../engine/api/gasless/fetchGaslessSend"; -import { ToastDuration, useToaster } from "../../../components/toast/ToastProvider"; +import { useToaster } from "../../../components/toast/ToastProvider"; import { GaslessEstimateSuccess } from "../../../engine/api/gasless/fetchGaslessEstimate"; +import { queryClient } from "../../../engine/clients"; +import { isMintlessJetton } from "../../../utils/hintSortFilter"; export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { const authContext = useKeysAuth(); @@ -198,6 +200,8 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } const isGasless = fees.type === 'gasless' && fees.params.ok; + const queryCache = queryClient.getQueryCache(); + const isMintless = isMintlessJetton(queryCache, target.address.toString({ testOnly: isTestnet, bounceable: target.bounceable })); // Check amount if (!order.messages[0].amountAll && account!.balance < order.messages[0].amount && !isGasless) { @@ -267,7 +271,6 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { let lastBlock = await getLastBlock(); let seqno = await backoff('transfer-seqno', async () => fetchSeqno(client, lastBlock, selected!.address)); - // External message let msg: Cell; @@ -281,13 +284,13 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { timeout: Math.ceil(Date.now() / 1000) + 5 * 60, secretKey: walletKeys.keyPair.secretKey, sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS, - messages: (fees as { type: "gasless", value: bigint, params: GaslessEstimateSuccess }).params.messages.map(message => - internal({ + messages: (fees as { type: "gasless", value: bigint, params: GaslessEstimateSuccess }).params.messages.map(message => { + return internal({ to: message.address, value: BigInt(message.amount), - body: message.payload ? Cell.fromBoc(Buffer.from(message.payload, 'hex'))[0] : null - }) - ) + body: message.payload ? Cell.fromBoc(Buffer.from(message.payload, 'hex'))[0] : null, + }); + }) }); msg = beginCell() diff --git a/app/fragments/secure/ops/Order.ts b/app/fragments/secure/ops/Order.ts index 2c7f65a6a..67efcc757 100644 --- a/app/fragments/secure/ops/Order.ts +++ b/app/fragments/secure/ops/Order.ts @@ -137,7 +137,7 @@ export function createOrder(args: { amount: args.amount, amountAll: args.amountAll, payload: args.payload, - stateInit: args.stateInit, + stateInit: args.stateInit }], domain: args.domain, app: args.app @@ -191,7 +191,9 @@ export function createJettonOrder(args: { amount: bigint, tonAmount: bigint, txAmount: bigint, - payload: Cell | null + customPayload: Cell | null, + payload: Cell | null, + stateInit: Cell | null }, isTestnet: boolean): Order { // Resolve payload @@ -214,12 +216,11 @@ export function createJettonOrder(args: { .storeCoins(args.amount) .storeAddress(Address.parse(args.target)) .storeAddress(args.responseTarget) - .storeMaybeRef(null) + .storeMaybeRef(args.customPayload) .storeCoins(args.tonAmount) .storeMaybeRef(payload) .endCell(); - return { type: 'order', ...createOrder({ @@ -228,7 +229,7 @@ export function createJettonOrder(args: { payload: msg, amount: args.txAmount, amountAll: false, - stateInit: null + stateInit: args.stateInit }) }; } \ No newline at end of file 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; From 9b227f2c0d852ad6195aa8b00689be9366ecda37 Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 18 Sep 2024 14:39:14 +0200 Subject: [PATCH 02/12] Merge branch 'develop' into feat/hub-1404-mintless-support # Conflicts: # app/fragments/secure/components/TransferSingle.tsx --- .../secure/components/TransferSingle.tsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/app/fragments/secure/components/TransferSingle.tsx b/app/fragments/secure/components/TransferSingle.tsx index 86f6eab65..8200bb67b 100644 --- a/app/fragments/secure/components/TransferSingle.tsx +++ b/app/fragments/secure/components/TransferSingle.tsx @@ -27,7 +27,6 @@ import { clearLastReturnStrategy } from "../../../engine/tonconnect/utils"; import { useWalletVersion } from "../../../engine/hooks/useWalletVersion"; import { WalletContractV4, WalletContractV5R1 } from "@ton/ton"; import { fetchGaslessSend, GaslessSendError } from "../../../engine/api/gasless/fetchGaslessSend"; -import { useToaster } from "../../../components/toast/ToastProvider"; import { GaslessEstimateSuccess } from "../../../engine/api/gasless/fetchGaslessEstimate"; import { queryClient } from "../../../engine/clients"; import { isMintlessJetton } from "../../../utils/hintSortFilter"; @@ -41,9 +40,8 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { const account = useAccountLite(selected!.address); const commitCommand = useCommitCommand(); const registerPending = useRegisterPending(); - const [walletSettings,] = useWalletSettings(selected?.address); + const [walletSettings] = useWalletSettings(selected?.address); const [failed, setFailed] = useState(false); - const toaster = useToaster(); let { restricted, target, jettonTarget, text, order, job, fees, metadata, callback } = props; @@ -143,10 +141,14 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } }, []); + const [isGasless, setIsGasless] = useState(fees.type === 'gasless' && fees.params.ok); + const onGaslessSendFailed = useCallback((reason?: GaslessSendError | string) => { setFailed(true); let message; + let actions = [{ text: t('common.back'), onPress: goBack }]; + let title = t('transfer.error.gaslessFailed'); switch (reason) { case GaslessSendError.TryLater: @@ -156,20 +158,25 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { message = t('transfer.error.gaslessNotEnoughFundsMessage'); break; case GaslessSendError.Cooldown: + title = t('transfer.error.gaslessCooldownTitle'); message = t('transfer.error.gaslessCooldown'); + actions = [ + { text: t('transfer.error.gaslessCooldownWait'), onPress: goBack }, + { + text: t('transfer.error.gaslessCooldownPayTon'), onPress: () => { + setIsGasless(false); + setFailed(false); + } + }, + ]; + break; default: message = reason; break; } - Alert.alert(t('transfer.error.gaslessFailed'), - message, - [{ - text: t('common.back'), - onPress: goBack - }] - ); + Alert.alert(title, message, actions); }, []); // Confirmation @@ -199,10 +206,6 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } } - const isGasless = fees.type === 'gasless' && fees.params.ok; - const queryCache = queryClient.getQueryCache(); - const isMintless = isMintlessJetton(queryCache, target.address.toString({ testOnly: isTestnet, bounceable: target.bounceable })); - // Check amount if (!order.messages[0].amountAll && account!.balance < order.messages[0].amount && !isGasless) { Alert.alert(t('transfer.error.notEnoughCoins')); @@ -289,8 +292,9 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { to: message.address, value: BigInt(message.amount), body: message.payload ? Cell.fromBoc(Buffer.from(message.payload, 'hex'))[0] : null, - }); - }) + init: message.stateInit ? loadStateInit(Cell.fromBoc(Buffer.from(message.stateInit, 'hex'))[0].asSlice()) : null, + }) + ) }); msg = beginCell() @@ -354,7 +358,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { ? (contract as WalletContractV5R1).createTransfer(transferParams) : (contract as WalletContractV4).createTransfer(transferParams); - } catch (e) { + } catch { warn('Failed to create transfer'); return; } @@ -450,7 +454,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } else { navigation.popToTop(); } - }, [registerPending, jettonAmountString, jetton, fees]); + }, [registerPending, jettonAmountString, jetton, fees, isGasless]); return ( Date: Wed, 18 Sep 2024 15:01:33 +0200 Subject: [PATCH 03/12] fix: typo --- app/fragments/secure/components/TransferSingle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/fragments/secure/components/TransferSingle.tsx b/app/fragments/secure/components/TransferSingle.tsx index 8200bb67b..894517221 100644 --- a/app/fragments/secure/components/TransferSingle.tsx +++ b/app/fragments/secure/components/TransferSingle.tsx @@ -294,7 +294,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { body: message.payload ? Cell.fromBoc(Buffer.from(message.payload, 'hex'))[0] : null, init: message.stateInit ? loadStateInit(Cell.fromBoc(Buffer.from(message.stateInit, 'hex'))[0].asSlice()) : null, }) - ) + }) }); msg = beginCell() From 79654e041fdfd405b19c429d233890b167043e6c Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 18 Sep 2024 18:03:59 +0200 Subject: [PATCH 04/12] Merge branch 'develop' into feat/hub-1404-mintless-support # Conflicts: # app/fragments/secure/SimpleTransferFragment.tsx --- app/fragments/secure/SimpleTransferFragment.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/fragments/secure/SimpleTransferFragment.tsx b/app/fragments/secure/SimpleTransferFragment.tsx index c9b30408d..ed781b2d0 100644 --- a/app/fragments/secure/SimpleTransferFragment.tsx +++ b/app/fragments/secure/SimpleTransferFragment.tsx @@ -80,6 +80,7 @@ export const SimpleTransferFragment = fragment(() => { const client = useClient4(network.isTestnet); const [price, currency] = usePrice(); const gaslessConfig = useGaslessConfig(); + const gaslessConfigLoading = gaslessConfig?.isFetching || gaslessConfig?.isLoading; // Ledger const ledgerContext = useLedgerTransport(); @@ -1189,7 +1190,8 @@ export const SimpleTransferFragment = fragment(() => { onPress={onNext ? onNext : undefined} /> : Date: Wed, 18 Sep 2024 18:50:41 +0200 Subject: [PATCH 05/12] Merge branch 'develop' into feat/hub-1404-mintless-support # Conflicts: # app/fragments/secure/SimpleTransferFragment.tsx --- app/fragments/secure/SimpleTransferFragment.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/fragments/secure/SimpleTransferFragment.tsx b/app/fragments/secure/SimpleTransferFragment.tsx index ed781b2d0..a22ced760 100644 --- a/app/fragments/secure/SimpleTransferFragment.tsx +++ b/app/fragments/secure/SimpleTransferFragment.tsx @@ -1190,10 +1190,9 @@ export const SimpleTransferFragment = fragment(() => { onPress={onNext ? onNext : undefined} /> : } From 73ff17d4524fcf251707dc1864d962a8d707534d Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Wed, 18 Sep 2024 23:26:31 +0200 Subject: [PATCH 06/12] fix: fixing jetton previews --- .../wallet/TransactionPreviewFragment.tsx | 43 ++++++++++--------- .../wallet/views/TransactionView.tsx | 14 +++--- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/app/fragments/wallet/TransactionPreviewFragment.tsx b/app/fragments/wallet/TransactionPreviewFragment.tsx index d299a5a73..786bcbbc8 100644 --- a/app/fragments/wallet/TransactionPreviewFragment.tsx +++ b/app/fragments/wallet/TransactionPreviewFragment.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from "react"; -import { Platform, ScrollView, Text, View, Image } from "react-native"; +import { Platform, ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { fragment } from "../../fragment"; import { getAppState } from "../../storage/appState"; @@ -17,10 +17,9 @@ import { ToastDuration, useToaster } from '../../components/toast/ToastProvider' import { ScreenHeader } from "../../components/ScreenHeader"; import { ItemGroup } from "../../components/ItemGroup"; import { AboutIconButton } from "../../components/AboutIconButton"; -import { useAppState, useBounceableWalletFormat, useDontShowComments, useJettonMaster, useKnownJettons, useNetwork, usePeparedMessages, usePrice, useSelectedAccount, useServerConfig, useSpamMinAmount, useTheme, useVerifyJetton, useWalletsSettings } from "../../engine/hooks"; +import { useAppState, useBounceableWalletFormat, useDontShowComments, useJetton, useJettonMaster, useJettonWallet, useKnownJettons, useNetwork, usePeparedMessages, usePrice, useSelectedAccount, useServerConfig, useSpamMinAmount, useTheme, useVerifyJetton, useWalletsSettings } from "../../engine/hooks"; import { useRoute } from "@react-navigation/native"; import { TransactionDescription } from "../../engine/types"; -import { BigMath } from "../../utils/BigMath"; import { useLedgerTransport } from "../ledger/components/TransportContext"; import { Address, fromNano } from "@ton/core"; import { StatusBar } from "expo-status-bar"; @@ -55,9 +54,9 @@ const TransactionPreview = () => { 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 ( Date: Thu, 19 Sep 2024 10:27:46 +0200 Subject: [PATCH 07/12] wip: fixing transfer view for mintless txs --- app/engine/hooks/jettons/useHints.ts | 17 +++++--- .../hooks/jettons/useSortedHintsWatcher.ts | 23 +++++++++-- app/fragments/secure/TransferFragment.tsx | 24 +++++++++-- .../secure/components/TransferSingle.tsx | 10 +++-- .../secure/components/TransferSingleView.tsx | 40 ++++--------------- 5 files changed, 65 insertions(+), 49 deletions(-) diff --git a/app/engine/hooks/jettons/useHints.ts b/app/engine/hooks/jettons/useHints.ts index 36a4b9089..de5bd5714 100644 --- a/app/engine/hooks/jettons/useHints.ts +++ b/app/engine/hooks/jettons/useHints.ts @@ -62,14 +62,19 @@ export function useMintlessHints(addressString?: string): MintlessJetton[] { try { const fetched = await fetchMintlessHints(addressString!); + const cache = queryClient.getQueryCache(); // update jetton wallets with mintless hints fetched?.forEach(hint => { - queryClient.setQueryData(Queries.Account(hint.walletAddress.address).JettonWallet(), { - balance: hint.balance, - owner: addressString!, - master: hint.jetton.address, - address: hint.walletAddress.address - }); + 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; diff --git a/app/engine/hooks/jettons/useSortedHintsWatcher.ts b/app/engine/hooks/jettons/useSortedHintsWatcher.ts index 455f036fd..13b7c7030 100644 --- a/app/engine/hooks/jettons/useSortedHintsWatcher.ts +++ b/app/engine/hooks/jettons/useSortedHintsWatcher.ts @@ -83,6 +83,9 @@ enum HintType { 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); @@ -93,13 +96,25 @@ export function useSortedHintsWatcher(address?: string) { const mintlessHints = getQueryData(cache, Queries.Mintless(address ?? '')); const allHints = [ - ...(hints || []).map((h) => ({ hintType: HintType.Hint as HintType.Hint, address: h })), - ...(mintlessHints || []).map((h) => ({ hintType: HintType.Mintless as HintType.Mintless, hint: h })) + ...(hints || []).map((h) => ({ hintType: HintType.Hint, address: h })), + ...(mintlessHints || []).map((h) => ({ hintType: HintType.Mintless, address: h.walletAddress.address, hint: h })) ] - const sorted = allHints + 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 === 'hint') { + if (h.hintType === HintType.Hint) { return getHint(cache, h.address, isTestnet); } diff --git a/app/fragments/secure/TransferFragment.tsx b/app/fragments/secure/TransferFragment.tsx index 3f87dd5af..9ccf4396e 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 } @@ -266,7 +267,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 @@ -314,6 +315,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; @@ -336,10 +352,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, @@ -359,7 +377,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 3c0517aeb..8422f1c26 100644 --- a/app/fragments/secure/components/TransferSingle.tsx +++ b/app/fragments/secure/components/TransferSingle.tsx @@ -28,8 +28,6 @@ import { useWalletVersion } from "../../../engine/hooks/useWalletVersion"; import { WalletContractV4, WalletContractV5R1 } from "@ton/ton"; import { fetchGaslessSend, GaslessSendError } from "../../../engine/api/gasless/fetchGaslessSend"; import { GaslessEstimateSuccess } from "../../../engine/api/gasless/fetchGaslessEstimate"; -import { queryClient } from "../../../engine/clients"; -import { isMintlessJetton } from "../../../utils/hintSortFilter"; export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { const authContext = useKeysAuth(); @@ -45,7 +43,11 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { let { restricted, target, jettonTarget, text, order, job, fees, metadata, callback } = props; - 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; @@ -111,7 +113,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } } }, []); - + if (jettonTarget) { target = jettonTarget; } diff --git a/app/fragments/secure/components/TransferSingleView.tsx b/app/fragments/secure/components/TransferSingleView.tsx index 568711081..23ae7961c 100644 --- a/app/fragments/secure/components/TransferSingleView.tsx +++ b/app/fragments/secure/components/TransferSingleView.tsx @@ -384,18 +384,10 @@ export const TransferSingleView = memo(({ - + {t('common.send')} - + - + {t('common.from')} - + {!!from.name && ( @@ -500,10 +485,7 @@ export const TransferSingleView = memo(({ {to.address.toString({ testOnly: isTestnet, bounceable: target.bounceable }).replaceAll('-', '\u2011')} @@ -539,10 +521,7 @@ export const TransferSingleView = memo(({ <> - + {t('transfer.smartContract')} @@ -583,10 +562,7 @@ export const TransferSingleView = memo(({ <> - + {t('transfer.smartContract')} From 4e99528618555bcc731b96261101a9ee610a88aa Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 19 Sep 2024 16:17:38 +0200 Subject: [PATCH 08/12] Merge branch 'develop' into feat/hub-1404-mintless-support # Conflicts: # app/fragments/secure/components/TransferSingle.tsx --- .../secure/components/TransferSingle.tsx | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/fragments/secure/components/TransferSingle.tsx b/app/fragments/secure/components/TransferSingle.tsx index 8422f1c26..64144d5a6 100644 --- a/app/fragments/secure/components/TransferSingle.tsx +++ b/app/fragments/secure/components/TransferSingle.tsx @@ -38,16 +38,13 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { const account = useAccountLite(selected!.address); const commitCommand = useCommitCommand(); const registerPending = useRegisterPending(); + + let { restricted, target, jettonTarget, text, order, job, fees, metadata, callback, onSetUseGasless, useGasless } = props; + const [walletSettings] = useWalletSettings(selected?.address); const [failed, setFailed] = useState(false); - let { restricted, target, jettonTarget, text, order, job, fees, metadata, callback } = props; - - 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; @@ -143,8 +140,6 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } }, []); - const [isGasless, setIsGasless] = useState(fees.type === 'gasless' && fees.params.ok); - const onGaslessSendFailed = useCallback((reason?: GaslessSendError | string) => { setFailed(true); @@ -162,15 +157,16 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { case GaslessSendError.Cooldown: title = t('transfer.error.gaslessCooldownTitle'); message = t('transfer.error.gaslessCooldown'); - actions = [ - { text: t('transfer.error.gaslessCooldownWait'), onPress: goBack }, - { + actions = [{ text: t('transfer.error.gaslessCooldownWait'), onPress: goBack }]; + + if (!!onSetUseGasless) { + actions.push({ text: t('transfer.error.gaslessCooldownPayTon'), onPress: () => { - setIsGasless(false); + onSetUseGasless?.(false); setFailed(false); } - }, - ]; + }) + } break; default: @@ -179,7 +175,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } Alert.alert(title, message, actions); - }, []); + }, [onSetUseGasless]); // Confirmation const doSend = useCallback(async () => { @@ -209,6 +205,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } // Check amount + const isGasless = fees.type === 'gasless' && fees.params.ok; if (!order.messages[0].amountAll && account!.balance < order.messages[0].amount && !isGasless) { Alert.alert(t('transfer.error.notEnoughCoins')); return; @@ -456,7 +453,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } else { navigation.popToTop(); } - }, [registerPending, jettonAmountString, jetton, fees, isGasless]); + }, [registerPending, jettonAmountString, jetton, fees]); return ( { isWithStateInit={!!order.messages[0].stateInit} contact={contact} failed={failed} - isGasless={isGasless} + isGasless={fees.type === 'gasless' && fees.params.ok} /> ); }); \ No newline at end of file From dc27a8d89389ae721776acdcc845e61efa64753d Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Thu, 19 Sep 2024 18:05:09 +0200 Subject: [PATCH 09/12] fix: fixing mintless balance sync --- app/engine/hooks/jettons/usePrefetchHints.ts | 30 +++++++++++++++++-- .../tryFetchJettonWalletIsClaimed.spec.ts | 24 +++++++++++++++ .../tryFetchJettonWalletIsClaimed.ts | 15 ++++++++++ .../secure/components/TransferSingle.tsx | 8 +++-- 4 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.spec.ts create mode 100644 app/engine/metadata/introspections/tryFetchJettonWalletIsClaimed.ts diff --git a/app/engine/hooks/jettons/usePrefetchHints.ts b/app/engine/hooks/jettons/usePrefetchHints.ts index eddcde997..92876be5f 100644 --- a/app/engine/hooks/jettons/usePrefetchHints.ts +++ b/app/engine/hooks/jettons/usePrefetchHints.ts @@ -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 @@ -290,6 +312,10 @@ export function usePrefetchHints(queryClient: QueryClient, address?: string) { 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) => { 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/fragments/secure/components/TransferSingle.tsx b/app/fragments/secure/components/TransferSingle.tsx index 64144d5a6..b5225e9a3 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; @@ -110,7 +114,7 @@ export const TransferSingle = memo((props: ConfirmLoadedPropsSingle) => { } } }, []); - + if (jettonTarget) { target = jettonTarget; } From 38cba73258fbc087397f5f0583f6629845ad6c1e Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 20 Sep 2024 17:45:56 +0200 Subject: [PATCH 10/12] fix: fixing loading state --- app/engine/hooks/jettons/useJettonPayload.ts | 6 ++++-- app/fragments/secure/SimpleTransferFragment.tsx | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/engine/hooks/jettons/useJettonPayload.ts b/app/engine/hooks/jettons/useJettonPayload.ts index 48f3bd5cb..815d93644 100644 --- a/app/engine/hooks/jettons/useJettonPayload.ts +++ b/app/engine/hooks/jettons/useJettonPayload.ts @@ -7,6 +7,8 @@ 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 () => { @@ -26,7 +28,7 @@ export function useJettonPayload(account?: string, masterAddress?: string) { const res = await fetchJettonPayload(account!, masterAddress!, customPayloadApiUri); return res; }, - enabled: !!account && !!masterAddress, + enabled, staleTime: 1000 * 5, refetchOnMount: true, refetchOnWindowFocus: true @@ -34,7 +36,7 @@ export function useJettonPayload(account?: string, masterAddress?: string) { return { data: query.data, - loading: query.isFetching || query.isLoading, + loading: (query.isFetching || query.isLoading) && enabled, isError: query.isError, } } \ No newline at end of file diff --git a/app/fragments/secure/SimpleTransferFragment.tsx b/app/fragments/secure/SimpleTransferFragment.tsx index a22ced760..5570055c7 100644 --- a/app/fragments/secure/SimpleTransferFragment.tsx +++ b/app/fragments/secure/SimpleTransferFragment.tsx @@ -852,6 +852,9 @@ export const SimpleTransferFragment = fragment(() => { })); }); + const continueDisabled = !order || gaslessConfigLoading || isJettonPayloadLoading; + const continueLoading = gaslessConfigLoading || isJettonPayloadLoading; + return ( @@ -1190,8 +1193,8 @@ export const SimpleTransferFragment = fragment(() => { onPress={onNext ? onNext : undefined} /> : From c9b5ebecb80c73f6db724597d00612a578cb03ba Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Fri, 20 Sep 2024 18:20:34 +0200 Subject: [PATCH 11/12] v2.3.15 --- VERSION_CODE | 2 +- ios/wallet/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION_CODE b/VERSION_CODE index 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/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", From 7e7fd8a424b66f27eb42f1993b43d242f3c243de Mon Sep 17 00:00:00 2001 From: vzhovnitsky Date: Mon, 23 Sep 2024 16:37:28 +0200 Subject: [PATCH 12/12] fix: add gas currency switch --- app/components/Item.tsx | 4 +++- .../secure/components/TransferSingle.tsx | 1 + .../secure/components/TransferSingleView.tsx | 17 ++++++++++++++--- app/i18n/i18n_en.ts | 3 ++- app/i18n/i18n_ru.ts | 3 ++- app/i18n/schema.ts | 1 + 6 files changed, 23 insertions(+), 6 deletions(-) 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/fragments/secure/components/TransferSingle.tsx b/app/fragments/secure/components/TransferSingle.tsx index b5225e9a3..408519474 100644 --- a/app/fragments/secure/components/TransferSingle.tsx +++ b/app/fragments/secure/components/TransferSingle.tsx @@ -478,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 f6713b5b7..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(); @@ -628,7 +631,15 @@ export const TransferSingleView = memo(({ - + {!!onSetUseGasless && ( + + )} {(amount > toNano('0.2') && !isGasless) && ( = { 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,