diff --git a/apps/web/src/config/abi/gelatoLimit.ts b/apps/web/src/config/abi/gelatoLimit.ts new file mode 100644 index 0000000000000..f4d558bea482e --- /dev/null +++ b/apps/web/src/config/abi/gelatoLimit.ts @@ -0,0 +1,363 @@ +export const gelatoLimitABI = [ + { + inputs: [{ internalType: 'address', name: '_gelato', type: 'address' }], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: '_key', type: 'bytes32' }, + { + indexed: true, + internalType: 'address', + name: '_caller', + type: 'address', + }, + { indexed: false, internalType: 'uint256', name: '_amount', type: 'uint256' }, + { + indexed: false, + internalType: 'bytes', + name: '_data', + type: 'bytes', + }, + ], + name: 'DepositETH', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: '_key', type: 'bytes32' }, + { + indexed: false, + internalType: 'address', + name: '_inputToken', + type: 'address', + }, + { indexed: false, internalType: 'address', name: '_owner', type: 'address' }, + { + indexed: false, + internalType: 'address', + name: '_witness', + type: 'address', + }, + { indexed: false, internalType: 'bytes', name: '_data', type: 'bytes' }, + { + indexed: false, + internalType: 'uint256', + name: '_amount', + type: 'uint256', + }, + ], + name: 'OrderCancelled', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: '_key', type: 'bytes32' }, + { + indexed: false, + internalType: 'address', + name: '_inputToken', + type: 'address', + }, + { indexed: false, internalType: 'address', name: '_owner', type: 'address' }, + { + indexed: false, + internalType: 'address', + name: '_witness', + type: 'address', + }, + { indexed: false, internalType: 'bytes', name: '_data', type: 'bytes' }, + { + indexed: false, + internalType: 'bytes', + name: '_auxData', + type: 'bytes', + }, + { indexed: false, internalType: 'uint256', name: '_amount', type: 'uint256' }, + { + indexed: false, + internalType: 'uint256', + name: '_bought', + type: 'uint256', + }, + ], + name: 'OrderExecuted', + type: 'event', + }, + { + inputs: [], + name: 'ETH_ADDRESS', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'GELATO', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IModule', + name: '_module', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'address', + name: '_witness', + type: 'address', + }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + { + internalType: 'bytes', + name: '_auxData', + type: 'bytes', + }, + ], + name: 'canExecuteOrder', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IModule', + name: '_module', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'address', + name: '_witness', + type: 'address', + }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + ], + name: 'cancelOrder', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: '_data', type: 'bytes' }], + name: 'decodeOrder', + outputs: [ + { internalType: 'address', name: 'module', type: 'address' }, + { + internalType: 'address', + name: 'inputToken', + type: 'address', + }, + { internalType: 'address payable', name: 'owner', type: 'address' }, + { + internalType: 'address', + name: 'witness', + type: 'address', + }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + { + internalType: 'bytes32', + name: 'secret', + type: 'bytes32', + }, + ], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: '_data', type: 'bytes' }], + name: 'depositEth', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '_module', type: 'address' }, + { + internalType: 'address', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'address', + name: '_witness', + type: 'address', + }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + { + internalType: 'bytes32', + name: '_secret', + type: 'bytes32', + }, + ], + name: 'encodeEthOrder', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IModule', + name: '_module', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'address', + name: '_witness', + type: 'address', + }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + { + internalType: 'bytes32', + name: '_secret', + type: 'bytes32', + }, + { internalType: 'uint256', name: '_amount', type: 'uint256' }, + ], + name: 'encodeTokenOrder', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + name: 'ethDeposits', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IModule', + name: '_module', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'bytes', + name: '_data', + type: 'bytes', + }, + { internalType: 'bytes', name: '_signature', type: 'bytes' }, + { + internalType: 'bytes', + name: '_auxData', + type: 'bytes', + }, + ], + name: 'executeOrder', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IModule', + name: '_module', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'address', + name: '_witness', + type: 'address', + }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + ], + name: 'existOrder', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IModule', + name: '_module', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'address', + name: '_witness', + type: 'address', + }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + ], + name: 'keyOf', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IModule', + name: '_module', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: '_inputToken', + type: 'address', + }, + { internalType: 'address payable', name: '_owner', type: 'address' }, + { + internalType: 'address', + name: '_witness', + type: 'address', + }, + { internalType: 'bytes', name: '_data', type: 'bytes' }, + ], + name: 'vaultOfOrder', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { stateMutability: 'payable', type: 'receive' }, +] diff --git a/apps/web/src/pages/api/query/transaction/index.ts b/apps/web/src/pages/api/query/transaction/index.ts new file mode 100644 index 0000000000000..ded0a2056e6f7 --- /dev/null +++ b/apps/web/src/pages/api/query/transaction/index.ts @@ -0,0 +1,63 @@ +import { gql } from 'graphql-request' +import { bitQueryServerClient } from 'utils/graphql' + +const cache = {} + +const CACHE_EXPIRATION_TIME = 15 * 60 * 1000 + +const cleanUpCache = () => { + const now = Date.now() + for (const key in cache) { + if (cache[key].timestamp + CACHE_EXPIRATION_TIME < now) { + delete cache[key] + } + } +} + +const GET_TRANSACTIONS = gql` + query GetTransactions($sender: String!, $to: String!) { + ethereum(network: bsc) { + transactions(txSender: { is: $sender }, txTo: { is: $to }) { + hash + } + } + } +` + +export default async function handler(req, res) { + const { sender, to } = req.query + + if (!sender || !to) { + return res.status(400).json({ error: 'Sender and To addresses are required.' }) + } + + // Clean up stale cache entries before processing the request + cleanUpCache() + + // Create a unique cache key based on the query parameters + const cacheKey = `${sender}_${to}` + + // Check if data is in the cache and if it's still valid + const cachedData = cache[cacheKey] + const isCacheValid = cachedData && Date.now() - cachedData.timestamp < CACHE_EXPIRATION_TIME + + if (isCacheValid) { + res.setHeader('Cache-Control', 'public, max-age=900') // Cache for 15 minutes + return res.status(200).json(cachedData.response) + } + + try { + // Execute the query + const data = await bitQueryServerClient.request(GET_TRANSACTIONS, { sender, to }) + const hashes = data.ethereum.transactions.map((tx) => tx.hash) + + const responseToCache = { hashes } + cache[cacheKey] = { response: responseToCache, timestamp: Date.now() } + + res.setHeader('Cache-Control', 'public, max-age=900') // Cache for 15 minutes + return res.status(200).json(responseToCache) + } catch (error) { + console.error(error) + return res.status(500).json({ error: 'Failed to fetch transaction data.' }) + } +} diff --git a/apps/web/src/views/LimitOrders/components/LimitOrderTable/ExistingLimitOrderTable.tsx b/apps/web/src/views/LimitOrders/components/LimitOrderTable/ExistingLimitOrderTable.tsx new file mode 100644 index 0000000000000..80ed0ce1ce0c2 --- /dev/null +++ b/apps/web/src/views/LimitOrders/components/LimitOrderTable/ExistingLimitOrderTable.tsx @@ -0,0 +1,135 @@ +import { useCallback } from 'react' +import { + Table, + Th, + Text, + Button, + BscScanIcon, + Link, + Flex, + Box, + Td, + useToast, + useMatchBreakpoints, +} from '@pancakeswap/uikit' +import { useTranslation } from '@pancakeswap/localization' +import { getBlockExploreLink } from 'utils' +import { ChainId } from '@pancakeswap/chains' +import { styled } from 'styled-components' +import useGelatoLimitOrdersLib from 'hooks/limitOrders/useGelatoLimitOrdersLib' +import { useAccount, usePublicClient, useWalletClient } from 'wagmi' +import { useActiveChainId } from 'hooks/useActiveChainId' +import { gelatoLimitABI } from 'config/abi/gelatoLimit' +import { ToastDescriptionWithTx } from 'components/Toast' +import useCatchTxError from 'hooks/useCatchTxError' +import truncateHash from '@pancakeswap/utils/truncateHash' +import { ExistingOrder } from 'views/LimitOrders/types' +import { useQueryClient } from '@tanstack/react-query' +import { EXISTING_ORDERS_QUERY_KEY } from 'views/LimitOrders/hooks/useGelatoLimitOrdersHistory' + +const RowStyle = styled.tr` + cursor: pointer; + + &:hover { + background: ${({ theme }) => theme.colors.backgroundDisabled}; + } +` + +const ExistingLimitOrderTable = ({ orders }: { orders: ExistingOrder[] }) => { + const { t } = useTranslation() + const { isMobile } = useMatchBreakpoints() + const { address } = useAccount() + const { chainId } = useActiveChainId() + const publicClient = usePublicClient({ chainId }) + const { data: walletClient } = useWalletClient() + const gelatoLimitOrders = useGelatoLimitOrdersLib() + const { toastSuccess } = useToast() + const { fetchWithCatchTxError } = useCatchTxError() + const queryClient = useQueryClient() + + const handleCancelOrder = useCallback( + async (order: ExistingOrder) => { + if (publicClient && gelatoLimitOrders?.contract.address && walletClient) { + const { request } = await publicClient.simulateContract({ + address: gelatoLimitOrders?.contract.address as `0x${string}`, + abi: gelatoLimitABI, + functionName: 'cancelOrder', + account: address, + args: [order.module, order.inputToken, order.owner, order.witness, order.data], + }) + + const receipt = await fetchWithCatchTxError(() => { + return walletClient.writeContract({ + ...request, + gas: 5000000n, + }) + }) + + if (receipt?.status) { + toastSuccess(t('Transaction receipt'), ) + queryClient.invalidateQueries({ + queryKey: [...EXISTING_ORDERS_QUERY_KEY, address], + }) + } + } + }, + [ + publicClient, + gelatoLimitOrders?.contract.address, + walletClient, + address, + t, + fetchWithCatchTxError, + queryClient, + toastSuccess, + ], + ) + + return ( + + <> + + + + + + + + {orders.map((order) => ( + + + + + ))} + + +
+ + Hash + + + + {t('Actions')} + +
+ + + + + {isMobile ? truncateHash(order.transactionHash) : order.transactionHash} + + + + + + + +
+ ) +} + +export default ExistingLimitOrderTable diff --git a/apps/web/src/views/LimitOrders/components/LimitOrderTable/NoOrdersMessage.tsx b/apps/web/src/views/LimitOrders/components/LimitOrderTable/NoOrdersMessage.tsx index 8d6918603eada..64017b18449d2 100644 --- a/apps/web/src/views/LimitOrders/components/LimitOrderTable/NoOrdersMessage.tsx +++ b/apps/web/src/views/LimitOrders/components/LimitOrderTable/NoOrdersMessage.tsx @@ -14,6 +14,8 @@ const NoOrdersMessage: React.FC> = memo( ({ orderCategory, isCompact }) => { @@ -15,13 +17,16 @@ const OrderTable: React.FC - {({ paginatedData }) => - isCompact ? ( + {({ paginatedData }) => { + if (orderCategory === ORDER_CATEGORY.Existing) { + return + } + return isCompact ? ( ) : ( ) - } + }} ) }, @@ -29,16 +34,18 @@ const OrderTable: React.FC> = ({ isCompact }) => { const { t } = useTranslation() - const [activeTab, setIndex] = useState(ORDER_CATEGORY.Open) - const handleClick = useCallback((tabType: ORDER_CATEGORY) => setIndex(tabType), []) + const [activeTab] = useState(ORDER_CATEGORY.Existing) const tabMenuItems = useMemo(() => { + if (activeTab === ORDER_CATEGORY.Existing) { + return [t('Existing Orders')] + } return [t('Open Orders'), t('Expired Order'), t('Order History')] - }, [t]) + }, [t, activeTab]) return ( - + diff --git a/apps/web/src/views/LimitOrders/hooks/useGelatoLimitOrdersHistory.ts b/apps/web/src/views/LimitOrders/hooks/useGelatoLimitOrdersHistory.ts index 0c24149755ccd..1adce7833e02e 100644 --- a/apps/web/src/views/LimitOrders/hooks/useGelatoLimitOrdersHistory.ts +++ b/apps/web/src/views/LimitOrders/hooks/useGelatoLimitOrdersHistory.ts @@ -1,19 +1,38 @@ import { GelatoLimitOrders, Order } from '@gelatonetwork/limit-orders-lib' import { SLOW_INTERVAL } from 'config/constants' -import { useMemo } from 'react' import useGelatoLimitOrdersLib from 'hooks/limitOrders/useGelatoLimitOrdersLib' import { getLSOrders, hashOrder, hashOrderSet, saveOrder, saveOrders } from 'utils/localStorageOrders' import { useQuery } from '@tanstack/react-query' import useAccountActiveChain from 'hooks/useAccountActiveChain' +import { usePublicClient } from 'wagmi' +import { gelatoLimitABI } from 'config/abi/gelatoLimit' +import { useMemo } from 'react' import orderBy from 'lodash/orderBy' -import { LimitOrderStatus, ORDER_CATEGORY } from '../types' +import { ExistingOrder, LimitOrderStatus, ORDER_CATEGORY } from '../types' +export const EXISTING_ORDERS_QUERY_KEY = ['limitOrders', 'gelato', 'existingOrders'] export const OPEN_ORDERS_QUERY_KEY = ['limitOrders', 'gelato', 'openOrders'] export const EXECUTED_CANCELLED_ORDERS_QUERY_KEY = ['limitOrders', 'gelato', 'cancelledExecutedOrders'] export const EXECUTED_EXPIRED_ORDERS_QUERY_KEY = ['limitOrders', 'gelato', 'expiredExecutedOrders'] +type DepositLog = { + key: string + transactionHash: string + caller: string + amount: string + blockNumber: number + data: { + module: string + inputToken: string + owner: string + witness: string + data: string + secret: string + } +} + function newOrdersFirst(a: Order, b: Order) { return Number(b.updatedAt) - Number(a.updatedAt) } @@ -69,6 +88,67 @@ async function syncOrderToLocalStorage({ }) } +const useExistingOrders = (turnOn: boolean): ExistingOrder[] => { + const { account, chainId } = useAccountActiveChain() + + const gelatoLimitOrders = useGelatoLimitOrdersLib() + + const provider = usePublicClient({ chainId }) + + const startFetch = turnOn && gelatoLimitOrders && account && chainId && provider + + const { data } = useQuery({ + queryKey: [...EXISTING_ORDERS_QUERY_KEY, account], + + queryFn: async () => { + if (!gelatoLimitOrders || !account || !chainId || !provider) { + throw new Error('Missing gelatoLimitOrders, account or chainId') + } + + try { + const response = await fetch(`https://proofs.pancakeswap.com/gelato/v1/${account}.log`) + + if (response.status === 404) { + return undefined + } + + const logs: DepositLog[] = await response.json() + + const existRoles = await provider.multicall({ + contracts: logs.map((log) => { + return { + abi: gelatoLimitABI, + address: gelatoLimitOrders.contract.address, + functionName: 'existOrder', + args: [log.data.module, log.data.inputToken, log.data.owner, log.data.witness, log.data.data], + } + }) as any[], + }) + + return logs + .filter((_, index) => existRoles[index]?.status === 'success' && existRoles[index]?.result) + .map((log) => ({ + transactionHash: log.transactionHash, + module: log.data.module, + inputToken: log.data.inputToken, + owner: log.data.owner, + witness: log.data.witness, + data: log.data.data, + })) + } catch (e) { + console.error('Error fetching logs or querying existOrder', e) + return undefined + } + }, + enabled: Boolean(startFetch), + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + + return useMemo(() => data ?? [], [data]) +} + const useOpenOrders = (turnOn: boolean): Order[] => { const { account, chainId } = useAccountActiveChain() @@ -215,6 +295,7 @@ export default function useGelatoLimitOrdersHistory(orderCategory: ORDER_CATEGOR const historyOrders = useHistoryOrders(orderCategory === ORDER_CATEGORY.History) const openOrders = useOpenOrders(orderCategory === ORDER_CATEGORY.Open) const expiredOrders = useExpiredOrders(orderCategory === ORDER_CATEGORY.Expired) + const existingOrders = useExistingOrders(orderCategory === ORDER_CATEGORY.Existing) const orders = useMemo(() => { switch (orderCategory as ORDER_CATEGORY) { @@ -224,13 +305,19 @@ export default function useGelatoLimitOrdersHistory(orderCategory: ORDER_CATEGOR return historyOrders case ORDER_CATEGORY.Expired: return expiredOrders + case ORDER_CATEGORY.Existing: + return existingOrders default: return [] } - }, [orderCategory, openOrders, historyOrders, expiredOrders]) + }, [orderCategory, openOrders, historyOrders, expiredOrders, existingOrders]) - return useMemo( - () => (Array.isArray(orders) ? orderBy(orders, (order) => parseInt(order.createdAt), 'desc') : orders), - [orders], - ) + return useMemo(() => { + if (orderCategory === ORDER_CATEGORY.Existing) { + return orders + } + return Array.isArray(orders) + ? (orderBy(orders, (order: Order) => parseInt(order.createdAt), 'desc') as Order[]) + : orders + }, [orders, orderCategory]) } diff --git a/apps/web/src/views/LimitOrders/types.ts b/apps/web/src/views/LimitOrders/types.ts index 91b271753aeba..5dc2df4836baa 100644 --- a/apps/web/src/views/LimitOrders/types.ts +++ b/apps/web/src/views/LimitOrders/types.ts @@ -4,6 +4,16 @@ export enum ORDER_CATEGORY { Open = 0, Expired = 1, History = 2, + Existing = 3, +} + +export interface ExistingOrder { + transactionHash: string + module: string + inputToken: string + owner: string + witness: string + data: string } export enum LimitOrderStatus { diff --git a/packages/localization/src/config/translations.json b/packages/localization/src/config/translations.json index 6edc412a51591..19ec870a20914 100644 --- a/packages/localization/src/config/translations.json +++ b/packages/localization/src/config/translations.json @@ -3668,5 +3668,7 @@ "Visit the %page%": "Visit the %page%", "Simple Staking Dashboard": "Simple Staking Dashboard", "Select your stake and withdraw": "Select your stake and withdraw", - "Learn more here": "Learn more here" + "Learn more here": "Learn more here", + "No Existing Orders": "No Existing Orders", + "Existing Orders": "Existing Orders" }