From bf2f7cc8319cf4e04c54c230bca9f83766835d96 Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Mon, 20 Apr 2026 17:19:21 +0400 Subject: [PATCH 1/4] Allow to buy NFT in appkit minter demo app --- .../src/core/components/layout/app-router.tsx | 12 +- .../core/components/layout/app-sidebar.tsx | 3 +- apps/appkit-minter/src/core/configs/env.ts | 1 + .../nft_purchase/api/getgems-client.ts | 62 ++++++++ .../src/features/nft_purchase/api/types.ts | 64 ++++++++ .../components/collection-card.tsx | 41 +++++ .../components/collections-list.tsx | 28 ++++ .../nft_purchase/components/nft-card.tsx | 147 ++++++++++++++++++ .../nft_purchase/components/nfts-list.tsx | 74 +++++++++ .../components/purchase-modal.tsx | 116 ++++++++++++++ .../nft_purchase/hooks/use-collections.ts | 14 ++ .../nft_purchase/hooks/use-nfts-on-sale.ts | 21 +++ .../src/features/nft_purchase/index.ts | 10 ++ .../nft_purchase/lib/featured-collections.ts | 22 +++ apps/appkit-minter/src/pages/index.ts | 2 + .../pages/nft-purchase-collection-page.tsx | 38 +++++ .../src/pages/nft-purchase-page.tsx | 20 +++ apps/appkit-minter/vite.config.ts | 7 + 18 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts create mode 100644 apps/appkit-minter/src/features/nft_purchase/api/types.ts create mode 100644 apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx create mode 100644 apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx create mode 100644 apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx create mode 100644 apps/appkit-minter/src/features/nft_purchase/components/nfts-list.tsx create mode 100644 apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx create mode 100644 apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts create mode 100644 apps/appkit-minter/src/features/nft_purchase/hooks/use-nfts-on-sale.ts create mode 100644 apps/appkit-minter/src/features/nft_purchase/index.ts create mode 100644 apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts create mode 100644 apps/appkit-minter/src/pages/nft-purchase-collection-page.tsx create mode 100644 apps/appkit-minter/src/pages/nft-purchase-page.tsx diff --git a/apps/appkit-minter/src/core/components/layout/app-router.tsx b/apps/appkit-minter/src/core/components/layout/app-router.tsx index 81be506c6..e50f8840d 100644 --- a/apps/appkit-minter/src/core/components/layout/app-router.tsx +++ b/apps/appkit-minter/src/core/components/layout/app-router.tsx @@ -11,7 +11,15 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { useWatchBalance, useWatchTransactions, useWatchJettons } from '@ton/appkit-react'; import { toast } from 'sonner'; -import { MinterPage, StakingPage, SwapPage, OnrampPage, SignMessagePage } from '@/pages'; +import { + MinterPage, + StakingPage, + SwapPage, + OnrampPage, + SignMessagePage, + NftPurchasePage, + NftPurchaseCollectionPage, +} from '@/pages'; export const AppRouter: React.FC = () => { // Enable global real-time balance updates @@ -54,6 +62,8 @@ export const AppRouter: React.FC = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx b/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx index fe459cc0f..87eb21f93 100644 --- a/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx +++ b/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx @@ -7,7 +7,7 @@ */ import type React from 'react'; -import { Coins, ArrowLeftRight, Sparkles, BookOpen, Github, PenLine, CreditCard } from 'lucide-react'; +import { Coins, ArrowLeftRight, Sparkles, BookOpen, Github, PenLine, CreditCard, ShoppingBag } from 'lucide-react'; import { Link, NavLink } from 'react-router-dom'; import { AppLogo } from '../app-logo'; @@ -32,6 +32,7 @@ const NAV_LINKS: readonly { to: string; label: string; icon: React.ComponentType { to: '/swap', label: 'Swap', icon: ArrowLeftRight }, { to: '/staking', label: 'Staking', icon: Coins }, { to: '/onramp', label: 'Buy', icon: CreditCard }, + { to: '/buy-nft', label: 'Buy NFT', icon: ShoppingBag }, { to: '/sign', label: 'Sign Message', icon: PenLine }, ]; diff --git a/apps/appkit-minter/src/core/configs/env.ts b/apps/appkit-minter/src/core/configs/env.ts index 8b5feeadb..07e4b8ca8 100644 --- a/apps/appkit-minter/src/core/configs/env.ts +++ b/apps/appkit-minter/src/core/configs/env.ts @@ -10,3 +10,4 @@ export const ENV_TON_API_KEY_MAINNET = import.meta.env.VITE_TON_API_KEY ?? '25a9b2326a34b39a5fa4b264fb78fb4709e1bd576fc5e6b176639f5b71e94b0d'; export const ENV_TON_API_KEY_TESTNET = import.meta.env.VITE_TON_API_TESTNET_KEY ?? 'd852b54d062f631565761042cccea87fa6337c41eb19b075e6c7fb88898a3992'; +export const ENV_GETGEMS_API_KEY: string = import.meta.env.VITE_GETGEMS_API_KEY ?? ''; diff --git a/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts b/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts new file mode 100644 index 000000000..ac6722930 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { GetGemsBuyResponse, GetGemsEnvelope, GetGemsNftFull, GetGemsNftsOnSaleResponse } from './types'; + +import { ENV_GETGEMS_API_KEY } from '@/core/configs/env'; + +// The real GetGems API (https://api.getgems.io/public-api) does not send +// CORS headers for browser origins, so we route requests through the Vite +// dev-server proxy configured in vite.config.ts. +const BASE_URL = '/getgems-api'; + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + Accept: 'application/json', + Authorization: ENV_GETGEMS_API_KEY, + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...init?.headers, + }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`GetGems ${res.status} ${res.statusText}: ${text || path}`); + } + + const body = (await res.json()) as GetGemsEnvelope | T; + + if (typeof body === 'object' && body !== null && 'success' in body) { + const envelope = body as GetGemsEnvelope; + if (!envelope.success) { + throw new Error(`GetGems request failed: ${path}`); + } + return envelope.response; + } + + return body as T; +} + +export function fetchNftsOnSale(collectionAddress: string, limit = 30): Promise { + return request( + `/v1/nfts/on-sale/${encodeURIComponent(collectionAddress)}?limit=${limit}`, + ); +} + +export function fetchNft(nftAddress: string): Promise { + return request(`/v1/nft/${encodeURIComponent(nftAddress)}`); +} + +export function buildBuyTransaction(nftAddress: string, version: string): Promise { + return request(`/v1/nfts/buy-fix-price/${encodeURIComponent(nftAddress)}`, { + method: 'POST', + body: JSON.stringify({ version }), + }); +} diff --git a/apps/appkit-minter/src/features/nft_purchase/api/types.ts b/apps/appkit-minter/src/features/nft_purchase/api/types.ts new file mode 100644 index 000000000..18c48c8bf --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/api/types.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export interface GetGemsEnvelope { + success: boolean; + response: T; +} + +export interface GetGemsFixPriceSale { + type?: string; + fullPrice: string; + marketplaceFee?: string; + currency?: string; + version: string; + contractAddress?: string; +} + +export type GetGemsSale = GetGemsFixPriceSale | { type?: string; [key: string]: unknown }; + +export interface GetGemsNftOnSale { + address: string; + name?: string | null; + image?: string | null; + sale?: GetGemsSale | null; +} + +export interface GetGemsNftsOnSaleResponse { + items: GetGemsNftOnSale[]; + cursor?: string | null; +} + +export interface GetGemsNftFull { + address: string; + name?: string | null; + description?: string | null; + image?: string | null; + ownerAddress?: string | null; + sale?: GetGemsSale | null; +} + +export interface GetGemsBuyMessage { + to: string; + amount: string; + payload?: string | null; + stateInit?: string | null; +} + +export interface GetGemsBuyResponse { + uuid: string; + from?: string | null; + timeout: string; + list: GetGemsBuyMessage[]; +} + +export function isFixPriceSale(sale: GetGemsSale | null | undefined): sale is GetGemsFixPriceSale { + if (!sale) return false; + const candidate = sale as GetGemsFixPriceSale; + return typeof candidate.version === 'string' && typeof candidate.fullPrice === 'string'; +} diff --git a/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx b/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx new file mode 100644 index 000000000..53e2670ee --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import { Link } from 'react-router-dom'; +import { ImageIcon, ChevronRight } from 'lucide-react'; + +import type { FeaturedCollection } from '../lib/featured-collections'; + +interface CollectionCardProps { + collection: FeaturedCollection; +} + +export const CollectionCard: FC = ({ collection }) => { + return ( + +
+ {collection.image ? ( + {collection.name} + ) : ( + + )} +
+
+

{collection.name}

+ {collection.description && ( +

{collection.description}

+ )} +
+ + + ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx b/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx new file mode 100644 index 000000000..430910536 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; + +import { useCollections } from '../hooks/use-collections'; +import { CollectionCard } from './collection-card'; + +import { Card } from '@/core/components'; + +export const CollectionsList: FC = () => { + const collections = useCollections(); + + return ( + +
+ {collections.map((collection) => ( + + ))} +
+
+ ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx new file mode 100644 index 000000000..3bcc80968 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx @@ -0,0 +1,147 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState } from 'react'; +import type { FC } from 'react'; +import { Button, useSelectedWallet } from '@ton/appkit-react'; +import type { TransactionRequest, TransactionRequestMessage } from '@ton/appkit'; +import { Network } from '@ton/appkit'; +import { Image as ImageIcon, ShoppingCart } from 'lucide-react'; +import { toast } from 'sonner'; + +import { buildBuyTransaction, fetchNft } from '../api/getgems-client'; +import { isFixPriceSale } from '../api/types'; +import type { GetGemsNftOnSale } from '../api/types'; +import { PurchaseModal } from './purchase-modal'; +import type { PurchaseDetails } from './purchase-modal'; + +interface NftCardProps { + nft: GetGemsNftOnSale; +} + +function formatTonPrice(nano: bigint): string { + const whole = nano / 1_000_000_000n; + const frac = nano % 1_000_000_000n; + const fracStr = frac.toString().padStart(9, '0').slice(0, 4).replace(/0+$/, ''); + return fracStr ? `${whole}.${fracStr}` : `${whole}`; +} + +function safeBigInt(value: string): bigint { + try { + return BigInt(value); + } catch { + return 0n; + } +} + +export const NftCard: FC = ({ nft }) => { + const [wallet] = useSelectedWallet(); + const [isLoadingBuy, setIsLoadingBuy] = useState(false); + const [details, setDetails] = useState(null); + + const sale = isFixPriceSale(nft.sale) ? nft.sale : null; + const priceTon = sale ? formatTonPrice(safeBigInt(sale.fullPrice)) : null; + + const isMainnet = wallet?.getNetwork().chainId === Network.mainnet().chainId; + + const handleBuyClick = async () => { + if (isLoadingBuy) return; + setIsLoadingBuy(true); + try { + const fresh = await fetchNft(nft.address); + if (!isFixPriceSale(fresh.sale)) { + toast.error('This NFT is not on sale anymore'); + return; + } + + const buy = await buildBuyTransaction(nft.address, fresh.sale.version); + + const messages: TransactionRequestMessage[] = buy.list.map((item) => { + const message: TransactionRequestMessage = { address: item.to, amount: item.amount }; + if (item.payload) message.payload = item.payload; + if (item.stateInit) message.stateInit = item.stateInit; + return message; + }); + + const tx: TransactionRequest = { + validUntil: Math.floor(new Date(buy.timeout).getTime() / 1000), + messages, + }; + + const priceNano = safeBigInt(fresh.sale.fullPrice); + const totalNano = buy.list.reduce((acc, item) => acc + safeBigInt(item.amount), 0n); + const feeNano = totalNano > priceNano ? totalNano - priceNano : 0n; + + setDetails({ + nftName: fresh.name ?? nft.name ?? 'Untitled', + nftImage: fresh.image ?? nft.image, + priceTon: formatTonPrice(priceNano), + feeTon: formatTonPrice(feeNano), + totalTon: formatTonPrice(totalNano), + tx, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to prepare purchase'); + } finally { + setIsLoadingBuy(false); + } + }; + + return ( + <> +
+
+ {nft.image ? ( + {nft.name + ) : ( + + )} +
+
+

{nft.name ?? 'Untitled'}

+

{priceTon ? `${priceTon} TON` : 'Not for sale'}

+ {sale && ( +
+ {!wallet ? ( + + ) : !isMainnet ? ( + + ) : ( + + )} +
+ )} +
+
+ + {details && ( + { + if (!open) setDetails(null); + }} + details={details} + onPurchased={() => setDetails(null)} + /> + )} + + ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/nfts-list.tsx b/apps/appkit-minter/src/features/nft_purchase/components/nfts-list.tsx new file mode 100644 index 000000000..59d261f3d --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/nfts-list.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; +import type { FC } from 'react'; +import { AlertCircle, Image as ImageIcon } from 'lucide-react'; +import { Button } from '@ton/appkit-react'; + +import { useNftsOnSale } from '../hooks/use-nfts-on-sale'; +import { NftCard } from './nft-card'; +import { isFixPriceSale } from '../api/types'; + +import { Card } from '@/core/components'; + +interface NftsListProps { + collectionAddress: string; +} + +export const NftsList: FC = ({ collectionAddress }) => { + const { data, isLoading, isError, refetch } = useNftsOnSale(collectionAddress); + + const nfts = useMemo(() => (data?.items ?? []).filter((nft) => isFixPriceSale(nft.sale)), [data?.items]); + + if (isError) { + return ( + +
+ +

Failed to load NFTs

+ +
+
+ ); + } + + if (isLoading) { + return ( + +
+
+ Loading NFTs... +
+
+ ); + } + + if (nfts.length === 0) { + return ( + +
+ +

No NFTs currently on sale

+
+
+ ); + } + + return ( + +
+ {nfts.map((nft) => ( + + ))} +
+
+ ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx b/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx new file mode 100644 index 000000000..8162c42ef --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx @@ -0,0 +1,116 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState } from 'react'; +import type { FC } from 'react'; +import { AlertCircle, Image as ImageIcon, ShoppingCart } from 'lucide-react'; +import { Button, Modal, Send } from '@ton/appkit-react'; +import type { TransactionRequest } from '@ton/appkit'; +import { getErrorMessage } from '@ton/appkit'; +import { toast } from 'sonner'; + +export interface PurchaseDetails { + nftName: string; + nftImage?: string | null; + priceTon: string; + feeTon: string; + totalTon: string; + tx: TransactionRequest; +} + +interface PurchaseModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + details: PurchaseDetails; + onPurchased: () => void; +} + +export const PurchaseModal: FC = ({ open, onOpenChange, details, onPurchased }) => { + const [confirmed, setConfirmed] = useState(false); + + return ( + +
+
+
+ {details.nftImage ? ( + {details.nftName} + ) : ( + + )} +
+

{details.nftName}

+
+ +
+
+
+

NFT price

+

Includes service fee and royalties

+
+

{details.priceTon} TON

+
+
+
+

Network fee

+

Unused part will be refunded to your wallet

+
+

{details.feeTon} TON

+
+
+ +
+
+ +
+

Warning

+

+ You are buying an NFT without a verification mark. It may be a counterfeit item. + Double-check the NFT before purchase. +

+
+
+ +
+ + { + toast.success('Purchase sent'); + onPurchased(); + onOpenChange(false); + }} + onError={(error: Error) => toast.error(getErrorMessage(error))} + disabled={!confirmed} + > + {({ isLoading, onSubmit, disabled }) => ( + + )} + +
+
+ ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts b/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts new file mode 100644 index 000000000..2ff76991f --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { FEATURED_COLLECTIONS } from '../lib/featured-collections'; +import type { FeaturedCollection } from '../lib/featured-collections'; + +export function useCollections(): readonly FeaturedCollection[] { + return FEATURED_COLLECTIONS; +} diff --git a/apps/appkit-minter/src/features/nft_purchase/hooks/use-nfts-on-sale.ts b/apps/appkit-minter/src/features/nft_purchase/hooks/use-nfts-on-sale.ts new file mode 100644 index 000000000..891d64165 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/hooks/use-nfts-on-sale.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; + +import { fetchNftsOnSale } from '../api/getgems-client'; +import type { GetGemsNftsOnSaleResponse } from '../api/types'; + +export function useNftsOnSale(collectionAddress: string): UseQueryResult { + return useQuery({ + queryKey: ['getgems', 'nfts-on-sale', collectionAddress], + queryFn: () => fetchNftsOnSale(collectionAddress), + staleTime: 30_000, + }); +} diff --git a/apps/appkit-minter/src/features/nft_purchase/index.ts b/apps/appkit-minter/src/features/nft_purchase/index.ts new file mode 100644 index 000000000..2973e2802 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CollectionsList } from './components/collections-list'; +export { NftsList } from './components/nfts-list'; diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts new file mode 100644 index 000000000..721a63783 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export interface FeaturedCollection { + address: string; + name: string; + description?: string; + image?: string; +} + +export const FEATURED_COLLECTIONS: readonly FeaturedCollection[] = [ + { + address: 'EQCWB1WLs7rDJfYaeVxTZWnwQmrIFzbUcr-us-9aIn1ZNFpq', + name: 'Featured Collection', + description: 'Browse and buy NFTs from this collection on GetGems.', + }, +]; diff --git a/apps/appkit-minter/src/pages/index.ts b/apps/appkit-minter/src/pages/index.ts index 5b512d331..335754178 100644 --- a/apps/appkit-minter/src/pages/index.ts +++ b/apps/appkit-minter/src/pages/index.ts @@ -11,3 +11,5 @@ export { SwapPage } from './swap-page'; export { StakingPage } from './staking-page'; export { OnrampPage } from './onramp-page'; export { SignMessagePage } from './sign-message-page'; +export { NftPurchasePage } from './nft-purchase-page'; +export { NftPurchaseCollectionPage } from './nft-purchase-collection-page'; diff --git a/apps/appkit-minter/src/pages/nft-purchase-collection-page.tsx b/apps/appkit-minter/src/pages/nft-purchase-collection-page.tsx new file mode 100644 index 000000000..8f817d44d --- /dev/null +++ b/apps/appkit-minter/src/pages/nft-purchase-collection-page.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import { Link, useParams, Navigate } from 'react-router-dom'; +import { ChevronLeft } from 'lucide-react'; + +import { NftsList } from '@/features/nft_purchase'; +import { Layout } from '@/core/components'; + +export const NftPurchaseCollectionPage: FC = () => { + const { collectionAddress } = useParams<{ collectionAddress: string }>(); + + if (!collectionAddress) { + return ; + } + + return ( + +
+ + + Back to collections + + + +
+
+ ); +}; diff --git a/apps/appkit-minter/src/pages/nft-purchase-page.tsx b/apps/appkit-minter/src/pages/nft-purchase-page.tsx new file mode 100644 index 000000000..6a5e58312 --- /dev/null +++ b/apps/appkit-minter/src/pages/nft-purchase-page.tsx @@ -0,0 +1,20 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; + +import { CollectionsList } from '@/features/nft_purchase'; +import { Layout } from '@/core/components'; + +export const NftPurchasePage: FC = () => { + return ( + + + + ); +}; diff --git a/apps/appkit-minter/vite.config.ts b/apps/appkit-minter/vite.config.ts index 8541ac850..330576b1b 100644 --- a/apps/appkit-minter/vite.config.ts +++ b/apps/appkit-minter/vite.config.ts @@ -19,6 +19,13 @@ export default defineConfig({ server: { port: 5174, allowedHosts: ['localhost', '127.0.0.1', 'local.dev'], + proxy: { + '/getgems-api': { + target: 'https://api.getgems.io', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/getgems-api/, '/public-api'), + }, + }, }, resolve: { alias: { From 982cdac9f014a459f4545d72b7af3ed264b66191 Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Mon, 20 Apr 2026 17:39:41 +0400 Subject: [PATCH 2/4] Show collection info --- .../nft_purchase/api/getgems-client.ts | 12 ++++++- .../src/features/nft_purchase/api/types.ts | 8 +++++ .../components/collection-card.tsx | 31 ++++++++++++------- .../components/collections-list.tsx | 8 ++--- .../nft_purchase/hooks/use-collections.ts | 20 +++++++++--- .../nft_purchase/lib/featured-collections.ts | 15 +-------- 6 files changed, 59 insertions(+), 35 deletions(-) diff --git a/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts b/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts index ac6722930..5ecbeb7d0 100644 --- a/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts +++ b/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts @@ -6,7 +6,13 @@ * */ -import type { GetGemsBuyResponse, GetGemsEnvelope, GetGemsNftFull, GetGemsNftsOnSaleResponse } from './types'; +import type { + GetGemsBuyResponse, + GetGemsCollection, + GetGemsEnvelope, + GetGemsNftFull, + GetGemsNftsOnSaleResponse, +} from './types'; import { ENV_GETGEMS_API_KEY } from '@/core/configs/env'; @@ -44,6 +50,10 @@ async function request(path: string, init?: RequestInit): Promise { return body as T; } +export function fetchCollection(collectionAddress: string): Promise { + return request(`/v1/collection/${encodeURIComponent(collectionAddress)}`); +} + export function fetchNftsOnSale(collectionAddress: string, limit = 30): Promise { return request( `/v1/nfts/on-sale/${encodeURIComponent(collectionAddress)}?limit=${limit}`, diff --git a/apps/appkit-minter/src/features/nft_purchase/api/types.ts b/apps/appkit-minter/src/features/nft_purchase/api/types.ts index 18c48c8bf..ab100c74f 100644 --- a/apps/appkit-minter/src/features/nft_purchase/api/types.ts +++ b/apps/appkit-minter/src/features/nft_purchase/api/types.ts @@ -43,6 +43,14 @@ export interface GetGemsNftFull { sale?: GetGemsSale | null; } +export interface GetGemsCollection { + address: string; + name?: string | null; + description?: string | null; + image?: string | null; + ownerAddress?: string | null; +} + export interface GetGemsBuyMessage { to: string; amount: string; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx b/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx index 53e2670ee..b6e414686 100644 --- a/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx +++ b/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx @@ -8,32 +8,39 @@ import type { FC } from 'react'; import { Link } from 'react-router-dom'; -import { ImageIcon, ChevronRight } from 'lucide-react'; +import { ChevronRight, ImageIcon } from 'lucide-react'; -import type { FeaturedCollection } from '../lib/featured-collections'; +import { useCollection } from '../hooks/use-collections'; interface CollectionCardProps { - collection: FeaturedCollection; + address: string; } -export const CollectionCard: FC = ({ collection }) => { +export const CollectionCard: FC = ({ address }) => { + const { data, isLoading, isError } = useCollection(address); + + const name = data?.name ?? (isLoading ? 'Loading…' : 'Unknown collection'); + const description = data?.description; + const image = data?.image; + return ( -
- {collection.image ? ( - {collection.name} +
+ {image ? ( + {name} + ) : isLoading ? ( +
) : ( )}
-

{collection.name}

- {collection.description && ( -

{collection.description}

- )} +

{name}

+ {description && !isError &&

{description}

} + {isError &&

Failed to load collection info

}
diff --git a/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx b/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx index 430910536..8acbb02c3 100644 --- a/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx +++ b/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx @@ -8,19 +8,19 @@ import type { FC } from 'react'; -import { useCollections } from '../hooks/use-collections'; +import { useFeaturedCollectionAddresses } from '../hooks/use-collections'; import { CollectionCard } from './collection-card'; import { Card } from '@/core/components'; export const CollectionsList: FC = () => { - const collections = useCollections(); + const addresses = useFeaturedCollectionAddresses(); return (
- {collections.map((collection) => ( - + {addresses.map((address) => ( + ))}
diff --git a/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts b/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts index 2ff76991f..631acf644 100644 --- a/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts +++ b/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts @@ -6,9 +6,21 @@ * */ -import { FEATURED_COLLECTIONS } from '../lib/featured-collections'; -import type { FeaturedCollection } from '../lib/featured-collections'; +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; -export function useCollections(): readonly FeaturedCollection[] { - return FEATURED_COLLECTIONS; +import { fetchCollection } from '../api/getgems-client'; +import type { GetGemsCollection } from '../api/types'; +import { FEATURED_COLLECTION_ADDRESSES } from '../lib/featured-collections'; + +export function useFeaturedCollectionAddresses(): readonly string[] { + return FEATURED_COLLECTION_ADDRESSES; +} + +export function useCollection(address: string): UseQueryResult { + return useQuery({ + queryKey: ['getgems', 'collection', address], + queryFn: () => fetchCollection(address), + staleTime: 5 * 60_000, + }); } diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts index 721a63783..f4c2bdcd9 100644 --- a/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts +++ b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts @@ -6,17 +6,4 @@ * */ -export interface FeaturedCollection { - address: string; - name: string; - description?: string; - image?: string; -} - -export const FEATURED_COLLECTIONS: readonly FeaturedCollection[] = [ - { - address: 'EQCWB1WLs7rDJfYaeVxTZWnwQmrIFzbUcr-us-9aIn1ZNFpq', - name: 'Featured Collection', - description: 'Browse and buy NFTs from this collection on GetGems.', - }, -]; +export const FEATURED_COLLECTION_ADDRESSES: readonly string[] = ['EQCWB1WLs7rDJfYaeVxTZWnwQmrIFzbUcr-us-9aIn1ZNFpq']; From a208f2073c3c6ce8bf0ab38f1d4fc87b20defd40 Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Mon, 20 Apr 2026 18:54:32 +0400 Subject: [PATCH 3/4] Allow to buy for USDT --- .../src/features/nft_purchase/api/types.ts | 2 +- .../nft_purchase/components/nft-card.tsx | 33 +++++---------- .../components/purchase-modal.tsx | 14 ++++--- .../src/features/nft_purchase/lib/currency.ts | 41 +++++++++++++++++++ .../nft_purchase/lib/featured-collections.ts | 2 +- 5 files changed, 62 insertions(+), 30 deletions(-) create mode 100644 apps/appkit-minter/src/features/nft_purchase/lib/currency.ts diff --git a/apps/appkit-minter/src/features/nft_purchase/api/types.ts b/apps/appkit-minter/src/features/nft_purchase/api/types.ts index ab100c74f..452737f49 100644 --- a/apps/appkit-minter/src/features/nft_purchase/api/types.ts +++ b/apps/appkit-minter/src/features/nft_purchase/api/types.ts @@ -15,7 +15,7 @@ export interface GetGemsFixPriceSale { type?: string; fullPrice: string; marketplaceFee?: string; - currency?: string; + currency: string; version: string; contractAddress?: string; } diff --git a/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx index 3bcc80968..9be07b9ef 100644 --- a/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx +++ b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx @@ -17,6 +17,7 @@ import { toast } from 'sonner'; import { buildBuyTransaction, fetchNft } from '../api/getgems-client'; import { isFixPriceSale } from '../api/types'; import type { GetGemsNftOnSale } from '../api/types'; +import { formatAmount, formatPrice, getCurrencyDecimals, safeBigInt } from '../lib/currency'; import { PurchaseModal } from './purchase-modal'; import type { PurchaseDetails } from './purchase-modal'; @@ -24,20 +25,7 @@ interface NftCardProps { nft: GetGemsNftOnSale; } -function formatTonPrice(nano: bigint): string { - const whole = nano / 1_000_000_000n; - const frac = nano % 1_000_000_000n; - const fracStr = frac.toString().padStart(9, '0').slice(0, 4).replace(/0+$/, ''); - return fracStr ? `${whole}.${fracStr}` : `${whole}`; -} - -function safeBigInt(value: string): bigint { - try { - return BigInt(value); - } catch { - return 0n; - } -} +const TON_DECIMALS = 9; export const NftCard: FC = ({ nft }) => { const [wallet] = useSelectedWallet(); @@ -45,7 +33,7 @@ export const NftCard: FC = ({ nft }) => { const [details, setDetails] = useState(null); const sale = isFixPriceSale(nft.sale) ? nft.sale : null; - const priceTon = sale ? formatTonPrice(safeBigInt(sale.fullPrice)) : null; + const priceLabel = sale ? `${formatPrice(sale.fullPrice, sale.currency)} ${sale.currency}` : null; const isMainnet = wallet?.getNetwork().chainId === Network.mainnet().chainId; @@ -73,16 +61,17 @@ export const NftCard: FC = ({ nft }) => { messages, }; - const priceNano = safeBigInt(fresh.sale.fullPrice); - const totalNano = buy.list.reduce((acc, item) => acc + safeBigInt(item.amount), 0n); - const feeNano = totalNano > priceNano ? totalNano - priceNano : 0n; + const currency = fresh.sale.currency; + const priceDecimals = getCurrencyDecimals(currency); + const priceRaw = safeBigInt(fresh.sale.fullPrice); + const totalTonRaw = buy.list.reduce((acc, item) => acc + safeBigInt(item.amount), 0n); setDetails({ nftName: fresh.name ?? nft.name ?? 'Untitled', nftImage: fresh.image ?? nft.image, - priceTon: formatTonPrice(priceNano), - feeTon: formatTonPrice(feeNano), - totalTon: formatTonPrice(totalNano), + priceAmount: formatAmount(priceRaw, priceDecimals), + priceCurrency: currency, + networkFeeTon: formatAmount(totalTonRaw, TON_DECIMALS), tx, }); } catch (error) { @@ -104,7 +93,7 @@ export const NftCard: FC = ({ nft }) => {

{nft.name ?? 'Untitled'}

-

{priceTon ? `${priceTon} TON` : 'Not for sale'}

+

{priceLabel ?? 'Not for sale'}

{sale && (
{!wallet ? ( diff --git a/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx b/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx index 8162c42ef..7b7c1c9c5 100644 --- a/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx +++ b/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx @@ -17,9 +17,9 @@ import { toast } from 'sonner'; export interface PurchaseDetails { nftName: string; nftImage?: string | null; - priceTon: string; - feeTon: string; - totalTon: string; + priceAmount: string; + priceCurrency: string; + networkFeeTon: string; tx: TransactionRequest; } @@ -53,14 +53,16 @@ export const PurchaseModal: FC = ({ open, onOpenChange, deta

NFT price

Includes service fee and royalties

-

{details.priceTon} TON

+

+ {details.priceAmount} {details.priceCurrency} +

Network fee

Unused part will be refunded to your wallet

-

{details.feeTon} TON

+

{details.networkFeeTon} TON

@@ -106,7 +108,7 @@ export const PurchaseModal: FC = ({ open, onOpenChange, deta loading={isLoading} icon={} > - Buy for {details.totalTon} TON + Buy for {details.priceAmount} {details.priceCurrency} )} diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/currency.ts b/apps/appkit-minter/src/features/nft_purchase/lib/currency.ts new file mode 100644 index 000000000..c1e21ce3c --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/lib/currency.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +const CURRENCY_DECIMALS: Record = { + TON: 9, + USDT: 6, + NOT: 9, + DOGS: 9, + HMSTR: 9, +}; + +export function getCurrencyDecimals(currency: string): number { + return CURRENCY_DECIMALS[currency.toUpperCase()] ?? 9; +} + +export function safeBigInt(value: string): bigint { + try { + return BigInt(value); + } catch { + return 0n; + } +} + +export function formatAmount(raw: bigint, decimals: number, maxFraction = 4): string { + if (decimals <= 0) return raw.toString(); + const base = 10n ** BigInt(decimals); + const whole = raw / base; + const frac = raw % base; + const fracStr = frac.toString().padStart(decimals, '0').slice(0, Math.max(0, maxFraction)).replace(/0+$/, ''); + return fracStr ? `${whole}.${fracStr}` : `${whole}`; +} + +export function formatPrice(rawAmount: string, currency: string): string { + const decimals = getCurrencyDecimals(currency); + return formatAmount(safeBigInt(rawAmount), decimals); +} diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts index f4c2bdcd9..79bd8b6f5 100644 --- a/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts +++ b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts @@ -6,4 +6,4 @@ * */ -export const FEATURED_COLLECTION_ADDRESSES: readonly string[] = ['EQCWB1WLs7rDJfYaeVxTZWnwQmrIFzbUcr-us-9aIn1ZNFpq']; +export const FEATURED_COLLECTION_ADDRESSES: readonly string[] = ['EQC3dNlesgVD8YbAazcauIrXBPfiVhMMr5YYk2in0Mtsz0Bz']; From 28fc4104332c114cc594a2cb40179d418dddc13f Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Mon, 20 Apr 2026 20:55:29 +0400 Subject: [PATCH 4/4] Build transaction for purchasing NFT for USDT by ourself --- .../nft_purchase/components/nft-card.tsx | 113 +++++++++++++++--- .../src/features/nft_purchase/lib/jetton.ts | 61 ++++++++++ 2 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 apps/appkit-minter/src/features/nft_purchase/lib/jetton.ts diff --git a/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx index 9be07b9ef..744558793 100644 --- a/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx +++ b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx @@ -10,23 +10,82 @@ import { useState } from 'react'; import type { FC } from 'react'; import { Button, useSelectedWallet } from '@ton/appkit-react'; import type { TransactionRequest, TransactionRequestMessage } from '@ton/appkit'; -import { Network } from '@ton/appkit'; +import { getJettonWalletAddress, Network } from '@ton/appkit'; +import { beginCell, toNano } from '@ton/core'; import { Image as ImageIcon, ShoppingCart } from 'lucide-react'; import { toast } from 'sonner'; import { buildBuyTransaction, fetchNft } from '../api/getgems-client'; import { isFixPriceSale } from '../api/types'; -import type { GetGemsNftOnSale } from '../api/types'; +import type { GetGemsNftFull, GetGemsNftOnSale } from '../api/types'; import { formatAmount, formatPrice, getCurrencyDecimals, safeBigInt } from '../lib/currency'; +import { buildJettonTransferBody, getJettonMaster } from '../lib/jetton'; import { PurchaseModal } from './purchase-modal'; import type { PurchaseDetails } from './purchase-modal'; +import { appKit } from '@/core/configs/app-kit'; + interface NftCardProps { nft: GetGemsNftOnSale; } const TON_DECIMALS = 9; +/** Gas attached to the jetton_transfer (matches what GetGems' own UI sends). */ +const JETTON_BUY_GAS = toNano('0.5'); +/** TON forwarded with jetton_notify to the sale contract. */ +const JETTON_FORWARD_TON = toNano('0.35'); + +async function buildJettonBuyTransaction(args: { + nft: GetGemsNftFull; + userAddress: string; + currency: string; + priceNano: bigint; + saleContract: string; +}): Promise { + const master = getJettonMaster(args.currency); + if (!master) { + throw new Error(`Currency ${args.currency} is not supported yet`); + } + + const userJettonWallet = await getJettonWalletAddress(appKit, { + jettonAddress: master, + ownerAddress: args.userAddress, + }); + + const commentCell = beginCell().storeUint(0, 32).storeStringTail('Bought on getgems.io').endCell(); + + const payload = buildJettonTransferBody({ + queryId: BigInt(Date.now()), + jettonAmount: args.priceNano, + destination: args.saleContract, + responseDestination: args.userAddress, + forwardTonAmount: JETTON_FORWARD_TON, + forwardPayload: commentCell, + }); + + console.log('[nft_purchase] USDT buy tx', { + userJettonWallet, + saleContract: args.saleContract, + priceNano: args.priceNano.toString(), + buyGas: JETTON_BUY_GAS.toString(), + forwardTon: JETTON_FORWARD_TON.toString(), + }); + + const messages: TransactionRequestMessage[] = [ + { + address: userJettonWallet, + amount: JETTON_BUY_GAS.toString(), + payload, + }, + ]; + + return { + validUntil: Math.floor(Date.now() / 1000) + 300, + messages, + }; +} + export const NftCard: FC = ({ nft }) => { const [wallet] = useSelectedWallet(); const [isLoadingBuy, setIsLoadingBuy] = useState(false); @@ -38,7 +97,7 @@ export const NftCard: FC = ({ nft }) => { const isMainnet = wallet?.getNetwork().chainId === Network.mainnet().chainId; const handleBuyClick = async () => { - if (isLoadingBuy) return; + if (isLoadingBuy || !wallet) return; setIsLoadingBuy(true); try { const fresh = await fetchNft(nft.address); @@ -47,31 +106,47 @@ export const NftCard: FC = ({ nft }) => { return; } - const buy = await buildBuyTransaction(nft.address, fresh.sale.version); - - const messages: TransactionRequestMessage[] = buy.list.map((item) => { - const message: TransactionRequestMessage = { address: item.to, amount: item.amount }; - if (item.payload) message.payload = item.payload; - if (item.stateInit) message.stateInit = item.stateInit; - return message; - }); - - const tx: TransactionRequest = { - validUntil: Math.floor(new Date(buy.timeout).getTime() / 1000), - messages, - }; - const currency = fresh.sale.currency; const priceDecimals = getCurrencyDecimals(currency); const priceRaw = safeBigInt(fresh.sale.fullPrice); - const totalTonRaw = buy.list.reduce((acc, item) => acc + safeBigInt(item.amount), 0n); + + let tx: TransactionRequest; + let networkFeeRaw: bigint; + + if (currency === 'TON') { + const buy = await buildBuyTransaction(nft.address, fresh.sale.version); + const messages: TransactionRequestMessage[] = buy.list.map((item) => { + const message: TransactionRequestMessage = { address: item.to, amount: item.amount }; + if (item.payload) message.payload = item.payload; + if (item.stateInit) message.stateInit = item.stateInit; + return message; + }); + tx = { + validUntil: Math.floor(new Date(buy.timeout).getTime() / 1000), + messages, + }; + networkFeeRaw = buy.list.reduce((acc, item) => acc + safeBigInt(item.amount), 0n); + } else { + const saleContract = fresh.sale.contractAddress; + if (!saleContract) { + throw new Error('Sale contract address is missing'); + } + tx = await buildJettonBuyTransaction({ + nft: fresh, + userAddress: wallet.getAddress(), + currency, + priceNano: priceRaw, + saleContract, + }); + networkFeeRaw = JETTON_BUY_GAS; + } setDetails({ nftName: fresh.name ?? nft.name ?? 'Untitled', nftImage: fresh.image ?? nft.image, priceAmount: formatAmount(priceRaw, priceDecimals), priceCurrency: currency, - networkFeeTon: formatAmount(totalTonRaw, TON_DECIMALS), + networkFeeTon: formatAmount(networkFeeRaw, TON_DECIMALS), tx, }); } catch (error) { diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/jetton.ts b/apps/appkit-minter/src/features/nft_purchase/lib/jetton.ts new file mode 100644 index 000000000..b938c9646 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/lib/jetton.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Cell } from '@ton/core'; +import { Address, beginCell } from '@ton/core'; + +export const JETTON_MASTERS: Record = { + USDT: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', +}; + +export function getJettonMaster(currency: string): string | undefined { + return JETTON_MASTERS[currency.toUpperCase()]; +} + +interface JettonTransferParams { + queryId: bigint; + jettonAmount: bigint; + destination: string; + responseDestination: string; + forwardTonAmount: bigint; + forwardPayload?: Cell | null; +} + +/** + * Build the body of a TEP-74 jetton_transfer internal message. + * Returns a Base64-encoded BoC ready to place in a TransactionRequestMessage.payload. + * + * For GetGems FixPriceSale USDT buys the sale contract recognizes the purchase + * from a jetton_notify with an empty forward_payload, so callers should pass + * forwardPayload=null. + */ +export function buildJettonTransferBody({ + queryId, + jettonAmount, + destination, + responseDestination, + forwardTonAmount, + forwardPayload, +}: JettonTransferParams): string { + const builder = beginCell() + .storeUint(0x0f8a7ea5, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(Address.parse(destination)) + .storeAddress(Address.parse(responseDestination)) + .storeBit(false) + .storeCoins(forwardTonAmount); + + if (forwardPayload) { + builder.storeBit(true).storeRef(forwardPayload); + } else { + builder.storeBit(false); + } + + return builder.endCell().toBoc().toString('base64'); +}