From 77c38f9772f1b0205836a331a7fb756e8e530ce4 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Wed, 20 Aug 2025 06:12:40 +0530 Subject: [PATCH 01/20] added GasTankPaymaster as a separate app --- src/apps/gas-tank/components/GasTank.tsx | 49 ++ .../gas-tank/components/GasTankHistory.tsx | 233 +++++++ src/apps/gas-tank/components/TopUpModal.tsx | 587 ++++++++++++++++++ .../gas-tank/components/UniversalGasTank.tsx | 257 ++++++++ src/apps/gas-tank/hooks/useGasTankBalance.tsx | 97 +++ src/apps/gas-tank/hooks/useOffer.tsx | 498 +++++++++++++++ src/apps/gas-tank/hooks/useReducerHooks.tsx | 6 + src/apps/gas-tank/icon.png | Bin 0 -> 20211 bytes src/apps/gas-tank/index.tsx | 39 ++ src/apps/gas-tank/manifest.json | 9 + src/apps/gas-tank/reducer/gasTankSlice.ts | 32 + src/apps/gas-tank/styles/gasTank.css | 13 + src/apps/gas-tank/utils/blockchain.ts | 141 +++++ src/apps/gas-tank/utils/converters.ts | 11 + src/apps/gas-tank/utils/sentry.ts | 244 ++++++++ src/apps/gas-tank/utils/types.tsx | 41 ++ src/apps/gas-tank/utils/wrappedTokens.ts | 44 ++ .../SendModal/SendModalTokensTabView.tsx | 80 ++- src/services/gasless.ts | 37 ++ 19 files changed, 2401 insertions(+), 17 deletions(-) create mode 100644 src/apps/gas-tank/components/GasTank.tsx create mode 100644 src/apps/gas-tank/components/GasTankHistory.tsx create mode 100644 src/apps/gas-tank/components/TopUpModal.tsx create mode 100644 src/apps/gas-tank/components/UniversalGasTank.tsx create mode 100644 src/apps/gas-tank/hooks/useGasTankBalance.tsx create mode 100644 src/apps/gas-tank/hooks/useOffer.tsx create mode 100644 src/apps/gas-tank/hooks/useReducerHooks.tsx create mode 100644 src/apps/gas-tank/icon.png create mode 100644 src/apps/gas-tank/index.tsx create mode 100644 src/apps/gas-tank/manifest.json create mode 100644 src/apps/gas-tank/reducer/gasTankSlice.ts create mode 100644 src/apps/gas-tank/styles/gasTank.css create mode 100644 src/apps/gas-tank/utils/blockchain.ts create mode 100644 src/apps/gas-tank/utils/converters.ts create mode 100644 src/apps/gas-tank/utils/sentry.ts create mode 100644 src/apps/gas-tank/utils/types.tsx create mode 100644 src/apps/gas-tank/utils/wrappedTokens.ts diff --git a/src/apps/gas-tank/components/GasTank.tsx b/src/apps/gas-tank/components/GasTank.tsx new file mode 100644 index 00000000..2e307256 --- /dev/null +++ b/src/apps/gas-tank/components/GasTank.tsx @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import styled from 'styled-components'; + +// components +import UniversalGasTank from './UniversalGasTank'; +import GasTankHistory from './GasTankHistory'; + +const GasTank = () => { + return ( + + + + + + + + + ); +}; + +const Container = styled.div` + display: flex; + gap: 40px; + max-width: 1200px; + margin: 0 auto; + padding: 32px; + + @media (max-width: 768px) { + flex-direction: column; + gap: 24px; + padding: 16px; + } +`; + +const LeftSection = styled.div` + flex: 1; + max-width: 400px; + + @media (max-width: 768px) { + max-width: 100%; + } +`; + +const RightSection = styled.div` + flex: 2; + min-width: 0; +`; + +export default GasTank; diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx new file mode 100644 index 00000000..688f7804 --- /dev/null +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -0,0 +1,233 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useState } from 'react'; +import styled from 'styled-components'; + +interface HistoryEntry { + id: string; + date: string; + type: 'Top-up' | 'Spend'; + amount: string; + token: { + symbol: string; + value: string; + icon: string; + }; +} + +const GasTankHistory = () => { + const [historyData] = useState([ + { + id: '1', + date: 'Apr 28, 11:20', + type: 'Top-up', + amount: '+$50.00', + token: { symbol: 'ETH', value: '0.0167', icon: '🔵' }, + }, + { + id: '2', + date: 'Apr 27, 16:45', + type: 'Spend', + amount: '-$2.30 (gas)', + token: { symbol: 'USDC', value: '2.30', icon: '🔵' }, + }, + { + id: '3', + date: 'Apr 27, 16:03', + type: 'Top-up', + amount: '-$110 (gas)', + token: { symbol: 'USDC', value: '119', icon: '🔵' }, + }, + { + id: '4', + date: 'Apr 27, 16:45', + type: 'Spend', + amount: '+$47.50', + token: { symbol: 'OP', value: '30.84', icon: '🔴' }, + }, + { + id: '5', + date: 'Apr 27, 16:03', + type: 'Top-up', + amount: '+$65.00', + token: { symbol: 'ETH', value: '0.0217', icon: '🔵' }, + }, + { + id: '6', + date: 'Apr 26, 15:14', + type: 'Spend', + amount: '-$3.10 (gas)', + token: { symbol: 'USDC', value: '3.10', icon: '🔵' }, + }, + { + id: '7', + date: 'Apr 26, 15:01', + type: 'Top-up', + amount: '+$75.00', + token: { symbol: 'BNB', value: '0.0250', icon: '🟡' }, + }, + { + id: '8', + date: 'Apr 25, 14:22', + type: 'Spend', + amount: '-$5.00 (gas)', + token: { symbol: 'USDC', value: '5.00', icon: '🔵' }, + }, + ]); + + const sortedHistory = historyData.sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + ); + + return ( + +
+ 📋 + Gas Tank History +
+ + + + Date ▲ + Type ▼ + Amount ▼ + Token ▼ + + + + {sortedHistory.map((entry) => ( + + {entry.date} + {entry.type} + + {entry.amount} + + + {entry.token.icon} + + {entry.token.value} + {entry.token.symbol} + + + + ))} + +
+
+ ); +}; + +const Container = styled.div` + background: #1a1a1a; + border-radius: 16px; + padding: 24px; + border: 1px solid #333; +`; + +const Header = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; +`; + +const Icon = styled.span` + font-size: 18px; +`; + +const Title = styled.h2` + color: #ffffff; + font-size: 18px; + font-weight: 600; + margin: 0; +`; + +const Table = styled.div` + width: 100%; +`; + +const TableHeader = styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #333; + margin-bottom: 8px; +`; + +const HeaderCell = styled.div` + color: #9ca3af; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const TableBody = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const TableRow = styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid #2a2a2a; + align-items: center; + + &:last-child { + border-bottom: none; + } +`; + +const DateCell = styled.div` + color: #ffffff; + font-size: 14px; +`; + +const TypeCell = styled.div` + color: #ffffff; + font-size: 14px; +`; + +const AmountCell = styled.div<{ isPositive: boolean }>` + color: ${(props) => (props.isPositive ? '#4ade80' : '#ef4444')}; + font-size: 14px; + font-weight: 600; +`; + +const TokenCell = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const TokenIcon = styled.span` + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +`; + +const TokenInfo = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const TokenValue = styled.span` + color: #ffffff; + font-size: 14px; + font-weight: 600; +`; + +const TokenSymbol = styled.span` + color: #9ca3af; + font-size: 12px; +`; + +export default GasTankHistory; diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx new file mode 100644 index 00000000..a02c38bf --- /dev/null +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -0,0 +1,587 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { CircularProgress } from '@mui/material'; +import { useState, useEffect } from 'react'; +import { BigNumber } from 'ethers'; +import { formatEther } from 'viem'; +import styled from 'styled-components'; + +// services +import { + convertPortfolioAPIResponseToToken, + useGetWalletPortfolioQuery, +} from '../../../services/pillarXApiWalletPortfolio'; +import { PortfolioToken, chainNameToChainIdTokensData } from '../../../services/tokensData'; + +// hooks +import useGlobalTransactionsBatch from '../../../hooks/useGlobalTransactionsBatch'; +import useBottomMenuModal from '../../../hooks/useBottomMenuModal'; +import { useAppDispatch, useAppSelector } from '../hooks/useReducerHooks'; +import useOffer from '../hooks/useOffer'; + +// redux +import { setWalletPortfolio } from '../reducer/gasTankSlice'; + +// utils +import { formatTokenAmount } from '../utils/converters'; + +// types +import { PortfolioData } from '../../../types/api'; + +interface TopUpModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { + const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; + const walletAddress = useWalletAddress(); + const { addToBatch } = useGlobalTransactionsBatch(); + const { showSend, setShowBatchSendModal } = useBottomMenuModal(); + const { getStepTransactions, getBestOffer } = useOffer(); + const dispatch = useAppDispatch(); + const [selectedToken, setSelectedToken] = useState( + null + ); + const [amount, setAmount] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [portfolioTokens, setPortfolioTokens] = useState([]); + + const walletPortfolio = useAppSelector( + (state) => state.swap.walletPortfolio as PortfolioData | undefined + ); + + // Get wallet portfolio + const { + data: walletPortfolioData, + isSuccess: isWalletPortfolioDataSuccess, + error: walletPortfolioDataError, + } = useGetWalletPortfolioQuery( + { wallet: walletAddress || '', isPnl: false }, + { skip: !walletAddress } + ); + + // Convert portfolio data to tokens when data changes + useEffect(() => { + if (walletPortfolioData && isWalletPortfolioDataSuccess) { + dispatch(setWalletPortfolio(walletPortfolioData?.result?.data)); + const tokens = convertPortfolioAPIResponseToToken( + walletPortfolioData.result.data + ); + setPortfolioTokens(tokens); + } + if (!isWalletPortfolioDataSuccess || walletPortfolioDataError) { + if (walletPortfolioDataError) { + console.error(walletPortfolioDataError); + } + dispatch(setWalletPortfolio(undefined)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + walletPortfolioData, + isWalletPortfolioDataSuccess, + walletPortfolioDataError, + ]); + + const handleTopUp = async () => { + if (!selectedToken || !amount || !walletAddress) return; + + setIsProcessing(true); + + try { + // Check if token is USDC + const isUSDC = selectedToken.symbol.toUpperCase() === 'USDC'; + + let receiveSwapAmount = amount; + + if (!isUSDC) { + // Need to swap to USDC first + try { + const bestOffer = await getBestOffer({ + fromTokenAddress: selectedToken.contract, + fromAmount: Number(amount), + fromChainId: chainNameToChainIdTokensData(selectedToken.blockchain), + fromTokenDecimals: selectedToken.decimals, + slippage: 0.03, + }); + if (!bestOffer) { + console.warn('No best offer found for swap'); + return; + } + const swapTransactions = await getStepTransactions( + bestOffer.offer, + walletAddress, + portfolioTokens, + Number(amount), + ); + + // Add swap transactions to batch + swapTransactions.forEach((tx, index) => { + const value = tx.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + addToBatch({ + title: `Swap to USDC ${index + 1}/${swapTransactions.length}`, + description: `Convert ${amount} ${selectedToken.symbol} to USDC for Gas Tank`, + to: tx.to, + value: integerValue, + data: tx.data, + chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + }); + }); + receiveSwapAmount = bestOffer.tokenAmountToReceive.toString(); + } catch (swapError) { + console.error('Error getting swap transactions:', swapError); + console.warn( + 'Failed to get swap route. Please try a different token or amount.' + ); + setIsProcessing(false); + return; + } + } + + // Call the paymaster API for USDC deposits + const response = await fetch( + `${paymasterUrl}/getTransactionForDeposit?chainId=${chainNameToChainIdTokensData(selectedToken.blockchain)}&amount=${receiveSwapAmount}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error('Failed to get deposit transaction'); + } + + const transactionData = await response.json(); + console.log('Transaction data:', transactionData); + + // Add transactions to batch + if (Array.isArray(transactionData.result)) { + transactionData.result.forEach((tx, index) => { + const value = tx.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + addToBatch({ + title: `Gas Tank Top-up ${index + 1}/${transactionData.result.length}`, + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: tx.to, + value: integerValue, + data: tx.data, + chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + }); + }); + } else { + const value = transactionData.result.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + // Single transaction + addToBatch({ + title: 'Gas Tank Top-up', + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: transactionData.result.to, + value: integerValue, + data: transactionData.result.data, + chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + }); + } + + // Show the send modal with the batched transactions + setShowBatchSendModal(true); + showSend(); + onSuccess?.(); + } catch (error) { + console.error('Error processing top-up:', error); + console.warn('Failed to process top-up. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + + const handleAmountChange = (value: string) => { + // Only allow numeric input + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setAmount(value); + } + }; + + const getMaxAmount = () => { + if (!selectedToken) return '0'; + return formatTokenAmount(selectedToken.balance); + }; + + if (!isOpen) return null; + + return ( + + +
+ Top up Gas Tank + +
+ + +
+ + {(() => { + if (!portfolioTokens) { + return ( + + + Loading wallet tokens... + + ); + } + if (walletPortfolioDataError) { + return ( + Failed to load wallet tokens + ); + } + return ( + + {portfolioTokens.map((token) => ( + setSelectedToken(token)} + $isSelected={ + selectedToken?.contract === token.contract && + selectedToken?.blockchain === + token.blockchain + } + > + + + + {token.symbol} + {token.name} + {token.blockchain} + + + + {formatTokenAmount(token.balance)} + + + ))} + + ); + })()} +
+ + {selectedToken && ( +
+ + + handleAmountChange(e.target.value)} + /> + setAmount(getMaxAmount())}> + MAX + + + + Available: {getMaxAmount()} {selectedToken.symbol} + +
+ )} + + + {(() => { + if (isProcessing) { + return ( + <> + + {'Processing...'} + + ); + } + if (selectedToken?.symbol?.toUpperCase() === 'USDC') { + return 'Add to Gas Tank'; + } + return 'Swap & Add to Gas Tank'; + })()} + +
+
+
+ ); +}; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +`; + +const ModalContainer = styled.div` + background: #1a1a1a; + border-radius: 20px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + border: 1px solid #333; +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px; + border-bottom: 1px solid #333; +`; + +const Title = styled.h2` + color: #ffffff; + font-size: 20px; + font-weight: 600; + margin: 0; +`; + +const CloseButton = styled.button` + background: rgba(255, 255, 255, 0.1); + color: white; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +`; + +const Content = styled.div` + padding: 24px; +`; + +const Section = styled.div` + margin-bottom: 24px; +`; + +const Label = styled.label` + display: block; + color: #ffffff; + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; +`; + +const LoadingContainer = styled.div` + display: flex; + align-items: center; + gap: 12px; + color: #9ca3af; + padding: 20px; + justify-content: center; +`; + +const ErrorMessage = styled.div` + color: #ef4444; + padding: 20px; + text-align: center; +`; + +const TokenList = styled.div` + max-height: 300px; + overflow-y: auto; + border: 1px solid #333; + border-radius: 12px; +`; + +const TokenItem = styled.div<{ $isSelected: boolean }>` + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + cursor: pointer; + border-bottom: 1px solid #2a2a2a; + background: ${(props) => (props.$isSelected ? '#7c3aed20' : 'transparent')}; + border-left: ${(props) => (props.$isSelected ? '3px solid #7c3aed' : 'none')}; + transition: background-color 0.2s; + + &:hover { + background: #2a2a2a; + } + + &:last-child { + border-bottom: none; + } +`; + +const TokenInfo = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const TokenLogo = styled.img` + width: 32px; + height: 32px; + border-radius: 50%; +`; + +const TokenDetails = styled.div` + display: flex; + flex-direction: column; +`; + +const TokenSymbol = styled.div` + color: #ffffff; + font-weight: 600; + font-size: 16px; +`; + +const TokenName = styled.div` + color: #9ca3af; + font-size: 12px; +`; + +const ChainName = styled.div` + color: #8b5cf6; + font-size: 11px; + font-weight: 500; +`; + +const TokenBalance = styled.div` + color: #ffffff; + font-weight: 600; +`; + +const AmountContainer = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const AmountInput = styled.input` + flex: 1; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + padding: 12px 16px; + color: #ffffff; + font-size: 16px; + + &:focus { + outline: none; + border-color: #7c3aed; + } + + &::placeholder { + color: #6b7280; + } +`; + +const MaxButton = styled.button` + background: #7c3aed; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: #6d28d9; + } +`; + +const BalanceInfo = styled.div` + color: #9ca3af; + font-size: 12px; + margin-top: 8px; +`; + +const TopUpButton = styled.button` + width: 100%; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: white; + border: none; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + +export default TopUpModal; diff --git a/src/apps/gas-tank/components/UniversalGasTank.tsx b/src/apps/gas-tank/components/UniversalGasTank.tsx new file mode 100644 index 00000000..d4d780fe --- /dev/null +++ b/src/apps/gas-tank/components/UniversalGasTank.tsx @@ -0,0 +1,257 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useState } from 'react'; +import styled from 'styled-components'; +import { CircularProgress } from '@mui/material'; + +// components +import TopUpModal from './TopUpModal'; + +// hooks +import useGasTankBalance from '../hooks/useGasTankBalance'; + +const UniversalGasTank = () => { + const { + totalBalance, + isLoading: isBalanceLoading, + error: balanceError, + refetch, + } = useGasTankBalance(); + const [totalSpend] = useState(82.97); + const [showTopUpModal, setShowTopUpModal] = useState(false); + + const handleTopUp = () => { + setShowTopUpModal(true); + }; + + return ( + +
+ + + Universal Gas Tank + + {!isBalanceLoading && !balanceError && ( + + 🔄 + + )} +
+ + + {(() => { + if (isBalanceLoading) { + return ( + + + Loading balance... + + ); + } + if (balanceError) { + return ( + + Error loading balance + Retry + + ); + } + return ${totalBalance.toFixed(2)}; + })()} + On All Networks + + + Top up + + + Top up your Gas Tank so you pay for network fees on every chain + + + + Total Spend: + ${totalSpend} + + + + The PillarX Gas Tank is your universal balance for covering transaction + fees across all networks. When you top up your Tank, you're + allocating tokens specifically for paying gas. You can increase your + balance anytime, and the tokens in your Tank can be used to pay network + fees on any supported chain. + + + setShowTopUpModal(false)} + onSuccess={() => { + setShowTopUpModal(false); + refetch(); + }} + /> +
+ ); +}; + +const Container = styled.div` + background: #1a1a1a; + border-radius: 16px; + padding: 24px; + border: 1px solid #333; +`; + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +`; + +const TitleSection = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const RefreshButton = styled.button` + background: rgba(139, 92, 246, 0.1); + color: #8b5cf6; + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + + &:hover { + background: rgba(139, 92, 246, 0.2); + border-color: rgba(139, 92, 246, 0.5); + transform: rotate(90deg); + } +`; + +const Icon = styled.span` + font-size: 18px; +`; + +const Title = styled.h2` + color: #ffffff; + font-size: 18px; + font-weight: 600; + margin: 0; +`; + +const BalanceSection = styled.div` + margin-bottom: 24px; +`; + +const Balance = styled.div` + color: #ffffff; + font-size: 36px; + font-weight: 700; + line-height: 1; + margin-bottom: 4px; +`; + +const LoadingBalance = styled.div` + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; + + span { + color: #8b5cf6; + font-size: 18px; + font-weight: 500; + } +`; + +const ErrorBalance = styled.div` + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; + + span { + color: #ef4444; + font-size: 18px; + font-weight: 500; + } +`; + +const RetryButton = styled.button` + background: #ef4444; + color: white; + border: none; + border-radius: 6px; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: #dc2626; + } +`; + +const NetworkLabel = styled.div` + color: #8b5cf6; + font-size: 14px; + font-weight: 500; +`; + +const TopUpButton = styled.button` + background: #7c3aed; + color: #ffffff; + border: none; + border-radius: 8px; + padding: 12px 24px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + margin-bottom: 16px; + transition: background-color 0.2s; + + &:hover { + background: #6d28d9; + } +`; + +const Description = styled.p` + color: #ffffff; + font-size: 14px; + line-height: 1.5; + margin: 0 0 16px 0; +`; + +const SpendInfo = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +`; + +const SpendLabel = styled.span` + color: #4ade80; + font-size: 14px; + font-weight: 500; +`; + +const SpendAmount = styled.span` + color: #4ade80; + font-size: 14px; + font-weight: 600; +`; + +const DetailedDescription = styled.p` + color: #9ca3af; + font-size: 12px; + line-height: 1.5; + margin: 0; + font-style: italic; +`; + +export default UniversalGasTank; diff --git a/src/apps/gas-tank/hooks/useGasTankBalance.tsx b/src/apps/gas-tank/hooks/useGasTankBalance.tsx new file mode 100644 index 00000000..ce125529 --- /dev/null +++ b/src/apps/gas-tank/hooks/useGasTankBalance.tsx @@ -0,0 +1,97 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useWalletAddress } from '@etherspot/transaction-kit'; + +interface ChainBalance { + chainId: string; + balance: string; +} + +interface GasTankBalanceResponse { + balance: { + [chainId: string]: ChainBalance; + }; +} + +interface UseGasTankBalanceReturn { + totalBalance: number; + chainBalances: ChainBalance[]; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +const useGasTankBalance = (): UseGasTankBalanceReturn => { + const walletAddress = useWalletAddress(); + const [totalBalance, setTotalBalance] = useState(0); + const [chainBalances, setChainBalances] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; + + const fetchGasTankBalance = useCallback(async () => { + if (!walletAddress) { + setTotalBalance(0); + setChainBalances([]); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${paymasterUrl}/getGasTankBalance?sender=${walletAddress}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch gas tank balance: ${response.status}`); + } + + const data: GasTankBalanceResponse = await response.json(); + + // Extract chain balances + const balances: ChainBalance[] = Object.values(data.balance || {}); + setChainBalances(balances); + + // Calculate total balance by summing all chain balances + const total = balances.reduce((sum, chainBalance) => { + const balance = parseFloat(chainBalance.balance) || 0; + return sum + balance; + }, 0); + + setTotalBalance(total); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Unknown error occurred'; + setError(errorMessage); + console.error('Error fetching gas tank balance:', err); + + // Set default values on error + setTotalBalance(0); + setChainBalances([]); + } finally { + setIsLoading(false); + } + }, [walletAddress]); + + // Initial fetch and when wallet address changes + useEffect(() => { + fetchGasTankBalance(); + }, [fetchGasTankBalance]); + + return { + totalBalance, + chainBalances, + isLoading, + error, + refetch: fetchGasTankBalance, + }; +}; + +export default useGasTankBalance; diff --git a/src/apps/gas-tank/hooks/useOffer.tsx b/src/apps/gas-tank/hooks/useOffer.tsx new file mode 100644 index 00000000..536c5379 --- /dev/null +++ b/src/apps/gas-tank/hooks/useOffer.tsx @@ -0,0 +1,498 @@ +import { + useEtherspotUtils, + useWalletAddress, +} from '@etherspot/transaction-kit'; +import { + LiFiStep, + Route, + RoutesRequest, + getRoutes, + getStepTransaction, +} from '@lifi/sdk'; +import { + createPublicClient, + encodeFunctionData, + erc20Abi, + formatUnits, + http, + parseUnits, + zeroAddress, +} from 'viem'; + +// types +import { StepTransaction, SwapOffer, SwapType } from '../utils/types'; + +// utils +import { + Token, +} from '../../../services/tokensData'; +import { getNetworkViem } from '../../deposit/utils/blockchain'; +import { + getNativeBalanceFromPortfolio, + processEth, + toWei, +} from '../utils/blockchain'; +import { + getWrappedTokenAddressIfNative, + isNativeToken, + isWrappedToken, +} from '../utils/wrappedTokens'; +import { + addExchangeBreadcrumb, + startExchangeTransaction, +} from '../utils/sentry'; + +const USDC_ADDRESSES: { [chainId: number]: string } = { + 137: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon + 42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum + 10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism + // Add more chains as needed +}; + +const useOffer = () => { + const { isZeroAddress } = useEtherspotUtils(); + const walletAddress = useWalletAddress(); + + /** + * Get native fee estimation for ERC20 tokens + * This function calculates how much native token (ETH, MATIC, etc.) is needed + * to pay for gas fees when swapping ERC20 tokens + */ + const getNativeFeeForERC20 = async ({ + tokenAddress, + chainId, + feeAmount, + slippage = 0.03, + }: { + tokenAddress: string; + chainId: number; + feeAmount: string; + slippage?: number; + }) => { + + try { + + /** + * Create route request to find the best path for converting + * the ERC20 token to native token for fee payment + */ + const feeRouteRequest: RoutesRequest = { + fromChainId: chainId, + toChainId: chainId, + fromTokenAddress: tokenAddress, + toTokenAddress: zeroAddress, + fromAmount: feeAmount, + options: { + slippage, + bridges: { + allow: ['relay'], + }, + exchanges: { allow: ['openocean', 'kyberswap'] }, + }, + }; + + const result = await getRoutes(feeRouteRequest); + const { routes } = result; + + const allOffers = routes as Route[]; + + if (allOffers.length) { + /** + * Find the best offer by comparing receive amounts + * The best offer is the one that gives the most native tokens + */ + const bestOffer = allOffers.reduce((a, b) => { + const receiveAmountA = processEth(a.toAmount, 18); + const receiveAmountB = processEth(b.toAmount, 18); + return receiveAmountA > receiveAmountB ? a : b; + }); + + return bestOffer; + } + + return undefined; + } catch (e) { + console.error('Failed to get native fee estimation via LiFi:', e); + return undefined; + } + }; + + /** + * Get the best swap offer for a given token pair + * This function finds the optimal route for swapping one token to another + * across different exchanges and bridges + */ + const getBestOffer = async ({ + fromAmount, + fromTokenAddress, + fromChainId, + fromTokenDecimals, + slippage = 0.03, + }: SwapType): Promise => { + try { + /** + * Step 1: Handle wrapped token conversion + * Replace native token addresses with their wrapped equivalents + * This is required for some DEX aggregators + */ + const fromTokenAddressWithWrappedCheck = getWrappedTokenAddressIfNative( + fromTokenAddress, + fromChainId + ); + + /** + * Step 2: Apply fee deduction using BigInt arithmetic + * Convert to wei first, then apply 1% fee deduction using integer math + * This prevents precision loss for large amounts or tokens with many decimals + */ + const fromAmountInWei = parseUnits(String(fromAmount), fromTokenDecimals); + const feeDeduction = fromAmountInWei / BigInt(100); // 1% fee + const fromAmountFeeDeducted = fromAmountInWei - feeDeduction; + const toTokenAddress = USDC_ADDRESSES[fromChainId]; + const toTokenDecimals = 6; // USDC has 6 decimals + /** + * Step 3: Create route request for LiFi + * This request includes all necessary parameters for finding swap routes + */ + const routesRequest: RoutesRequest = { + fromChainId, + toChainId: fromChainId, // Swapping within the same chain + fromTokenAddress: fromTokenAddressWithWrappedCheck, + toTokenAddress, + fromAmount: fromAmountFeeDeducted.toString(), + options: { + slippage, + bridges: { + allow: ['relay'], + }, + exchanges: { allow: ['openocean', 'kyberswap'] }, + }, + }; + + const result = await getRoutes(routesRequest); + const { routes } = result; + + const allOffers = routes as Route[]; + + if (allOffers.length) { + /** + * Step 4: Find the best offer + * Compare all available routes and select the one with the highest output + */ + const bestOffer = allOffers.reduce((a, b) => { + const receiveAmountA = processEth(a.toAmount, toTokenDecimals); + const receiveAmountB = processEth(b.toAmount, toTokenDecimals); + return receiveAmountA > receiveAmountB ? a : b; + }); + + const selectedOffer: SwapOffer = { + tokenAmountToReceive: processEth(bestOffer.toAmount, toTokenDecimals), + offer: bestOffer as Route, + }; + + return selectedOffer; + } + + // Return undefined instead of empty object when no routes found + return undefined; + } catch (e) { + console.error( + 'Sorry, an error occurred while trying to fetch the best swap offer. Please try again.', + e + ); + // Return undefined instead of empty object on error + return undefined; + } + }; + + /** + * Check if token allowance is set for a specific spender + * This function verifies if the wallet has approved a contract to spend tokens + */ + const isAllowanceSet = async ({ + owner, + spender, + tokenAddress, + chainId, + }: { + owner: string; + spender: string; + tokenAddress: string; + chainId: number; + }) => { + if (isZeroAddress(tokenAddress)) return undefined; + + // Validate inputs + if (!owner || !spender || !tokenAddress) { + console.warn('Invalid inputs for allowance check:', { + owner, + spender, + tokenAddress, + }); + return undefined; + } + + try { + const publicClient = createPublicClient({ + chain: getNetworkViem(chainId), + transport: http(), + }); + + const allowance = await publicClient.readContract({ + address: tokenAddress as `0x${string}`, + abi: erc20Abi, + functionName: 'allowance', + args: [owner as `0x${string}`, spender as `0x${string}`], + }); + + return allowance === BigInt(0) ? undefined : allowance; + } catch (error) { + console.error('Failed to check token allowance:', error); + return undefined; + } + }; + + /** + * Build step transactions for a swap + * This function creates the sequence of transactions needed to execute a swap + * including fee payments, approvals, and the actual swap + */ + const getStepTransactions = async ( + route: Route, + fromAccount: string, + userPortfolio: Token[] | undefined, + fromAmount: number // Pass the original user input amount + ): Promise => { + const stepTransactions: StepTransaction[] = []; + const fromTokenChainId = route.fromToken.chainId; + /** + * Step 1: Determine if wrapping is required + * Check if we need to wrap native tokens before swapping + */ + const isWrapRequired = isWrappedToken( + route.fromToken.address, + route.fromToken.chainId + ); + + // Convert fromAmount (number) to BigInt using token decimals + const fromAmountBigInt = parseUnits( + String(fromAmount), + 6 // Assuming USDC has 6 decimals + ); + + /** + * Step 2: Fee calculation and validation + * Calculate 1% platform fee and validate fee receiver address + */ + const feeReceiver = import.meta.env.VITE_SWAP_FEE_RECEIVER; + + // Validate fee receiver address + if (!feeReceiver) { + throw new Error('Fee receiver address is not configured'); + } + + /** + * Step 3: Balance checks + * Verify user has sufficient balance for swap and fees + */ + let userNativeBalance = BigInt(0); + try { + // Get native balance from portfolio + const nativeBalance = + getNativeBalanceFromPortfolio(userPortfolio, fromTokenChainId) || '0'; + userNativeBalance = toWei(nativeBalance, 18); + } catch (e) { + throw new Error('Unable to fetch balances for swap.'); + } + + console.log('totalNativeRequired', fromAmountBigInt); + // // Calculate total required + // const totalNativeRequired = fromAmountBigInt; + // if (userNativeBalance < totalNativeRequired) { + // throw new Error( + // 'Insufficient native token balance to cover swap and fee.' + // ); + // } + + /** + * Step 4: Wrap transaction (if required) + * If the route requires wrapped tokens but user has native tokens, + * add a wrapping transaction + */ + if (isWrapRequired) { + const wrapCalldata = encodeFunctionData({ + abi: [ + { + name: 'deposit', + type: 'function', + stateMutability: 'payable', + inputs: [], + outputs: [], + }, + ], + functionName: 'deposit', + }); + + stepTransactions.push({ + to: route.fromToken.address, // wrapped token address + data: wrapCalldata, + value: BigInt(route.fromAmount), + chainId: route.fromChainId, + }); + } + + /** + * Step 5: Process route steps + * Handle each step in the swap route, including approvals and swaps + */ + try { + // eslint-disable-next-line no-restricted-syntax + for (const step of route.steps) { + // --- APPROVAL LOGIC --- + // Only require approval for ERC20 tokens (never for native tokens, including special addresses like POL/MATIC) + const isTokenNative = isNativeToken(step.action.fromToken.address); + + // Validate required addresses before proceeding + if (!step.action.fromToken.address) { + throw new Error('Token address is undefined in step'); + } + + const isAllowance = isTokenNative + ? undefined // Native tokens never require approval + : // eslint-disable-next-line no-await-in-loop + await isAllowanceSet({ + owner: fromAccount, + spender: step.estimate.approvalAddress || '', + tokenAddress: step.action.fromToken.address, + chainId: step.action.fromChainId, + }); + + const isEnoughAllowance = isAllowance + ? formatUnits(isAllowance, step.action.fromToken.decimals) >= + formatUnits( + BigInt(step.action.fromAmount), + step.action.fromToken.decimals + ) + : undefined; + + // Here we are checking if this is not a native/gas token and if the allowance + // is not set, then we manually add an approve transaction + if (!isTokenNative && !isEnoughAllowance) { + // Validate approval address before using it + if (!step.estimate.approvalAddress) { + throw new Error( + 'Approval address is undefined for non-native token' + ); + } + + // We encode the callData for the approve transaction + const calldata = encodeFunctionData({ + abi: [ + { + inputs: [ + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'approve', + args: [ + step.estimate.approvalAddress as `0x${string}`, + BigInt(step.estimate.fromAmount), + ], + }); + + // We push the approve transaction to the stepTransactions array + stepTransactions.push({ + data: calldata, + value: BigInt(0), + to: step.action.fromToken.address, + chainId: step.action.fromChainId, + transactionType: 'approval', + }); + } + + const actionCopy = { ...step.action }; + + // This is to make sure we have a fromAddress, which is not always + // provided by Lifi + if (!actionCopy.fromAddress) { + actionCopy.fromAddress = fromAccount; + } + + // This is to make sure we have a toAddress, which is not always + // provided by Lifi, from and to address are the same + actionCopy.toAddress = actionCopy.fromAddress; + + const modifiedStep: LiFiStep = { ...step, action: actionCopy }; + + // eslint-disable-next-line no-await-in-loop + const updatedStep = await getStepTransaction(modifiedStep); + + if (!updatedStep.transactionRequest) { + throw new Error('No transactionRequest'); + } + + const { to, data, value, gasLimit, gasPrice, chainId, type } = + updatedStep.transactionRequest; + + // Validate the 'to' address before adding to stepTransactions + if (!to) { + throw new Error('Transaction "to" address is undefined'); + } + + // Handle bigint conversions properly for values from LiFi SDK + const valueBigInt = + typeof value === 'bigint' ? value : BigInt(String(value || 0)); + const gasLimitBigInt = + typeof gasLimit === 'bigint' + ? gasLimit + : BigInt(String(gasLimit || 0)); + const gasPriceBigInt = + typeof gasPrice === 'bigint' + ? gasPrice + : BigInt(String(gasPrice || 0)); + + stepTransactions.push({ + to, + data: data as `0x${string}`, + value: valueBigInt, + gasLimit: gasLimitBigInt, + gasPrice: gasPriceBigInt, + chainId, + type, + }); + } + } catch (error) { + console.error('Failed to get step transactions:', error); + throw error; // Re-throw so the UI can handle it + } + + return stepTransactions; + }; + + return { + getBestOffer, + getStepTransactions, + }; +}; + +export default useOffer; diff --git a/src/apps/gas-tank/hooks/useReducerHooks.tsx b/src/apps/gas-tank/hooks/useReducerHooks.tsx new file mode 100644 index 00000000..ec4857d0 --- /dev/null +++ b/src/apps/gas-tank/hooks/useReducerHooks.tsx @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from '../../../store'; + +// To use throughout the app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/apps/gas-tank/icon.png b/src/apps/gas-tank/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0838beddb246fe5180ced0dccb5aa47a2e85df38 GIT binary patch literal 20211 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 Mi6lXngfV&l1FV|+M*si- literal 0 HcmV?d00001 diff --git a/src/apps/gas-tank/index.tsx b/src/apps/gas-tank/index.tsx new file mode 100644 index 00000000..e1ca5fb7 --- /dev/null +++ b/src/apps/gas-tank/index.tsx @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import styled from 'styled-components'; + +// styles +import './styles/gasTank.css'; + +// components +import GasTank from './components/GasTank'; + +export const App = () => { + return ( + + + + ); +}; + +const Wrapper = styled.div` + display: flex; + width: 100%; + margin: 0 auto; + flex-direction: column; + max-width: 1248px; + padding: 32px; + + @media (min-width: 1024px) { + padding: 52px 62px; + } + + @media (max-width: 1024px) { + padding: 52px 32px; + } + + @media (max-width: 768px) { + padding: 32px 16px; + } +`; + +export default App; diff --git a/src/apps/gas-tank/manifest.json b/src/apps/gas-tank/manifest.json new file mode 100644 index 00000000..d98e4625 --- /dev/null +++ b/src/apps/gas-tank/manifest.json @@ -0,0 +1,9 @@ +{ + "title": "Gas Tank", + "description": "Universal Gas Tank for PillarX", + "translations": { + "en": { + "title": "Gas Tank by PillarX" + } + } +} diff --git a/src/apps/gas-tank/reducer/gasTankSlice.ts b/src/apps/gas-tank/reducer/gasTankSlice.ts new file mode 100644 index 00000000..9259d491 --- /dev/null +++ b/src/apps/gas-tank/reducer/gasTankSlice.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +// types +import { PortfolioData } from '../../../types/api'; + +export type SwapState = { + walletPortfolio: PortfolioData | undefined; +}; + +const initialState: SwapState = { + walletPortfolio: undefined, +}; + +const gasTankSlice = createSlice({ + name: 'gasTank', + initialState, + reducers: { + setWalletPortfolio( + state, + action: PayloadAction + ) { + state.walletPortfolio = action.payload; + }, + }, +}); + +export const { + setWalletPortfolio, +} = gasTankSlice.actions; + +export default gasTankSlice; diff --git a/src/apps/gas-tank/styles/gasTank.css b/src/apps/gas-tank/styles/gasTank.css new file mode 100644 index 00000000..b4585e72 --- /dev/null +++ b/src/apps/gas-tank/styles/gasTank.css @@ -0,0 +1,13 @@ +/* Gas Tank App Specific Styles */ +.gas-tank-app { + background: #0a0a0a; + min-height: 100vh; + color: white; +} + +/* Responsive overrides for gas tank */ +@media (max-width: 768px) { + .gas-tank-app { + padding: 16px; + } +} diff --git a/src/apps/gas-tank/utils/blockchain.ts b/src/apps/gas-tank/utils/blockchain.ts new file mode 100644 index 00000000..a044e039 --- /dev/null +++ b/src/apps/gas-tank/utils/blockchain.ts @@ -0,0 +1,141 @@ +import { BigNumber, BigNumberish } from 'ethers'; +import { formatEther, formatUnits } from 'ethers/lib/utils'; +import { decodeFunctionData, erc20Abi, parseUnits } from 'viem'; + +// types +import { + Token, + chainNameToChainIdTokensData, +} from '../../../services/tokensData'; +import { StepTransaction } from './types'; + +// utils +import { isNativeToken } from './wrappedTokens'; + +export const processBigNumber = (val: BigNumber): number => + Number(val.toString()); + +export const processEth = (val: BigNumberish, dec: number): number => { + if (typeof val === 'bigint') { + return +parseFloat(formatEther(val)).toFixed(2); + } + + return +parseFloat(formatUnits(val as BigNumberish, dec)); +}; + +// Utility: get native token symbol for a chain +export const NATIVE_SYMBOLS: Record = { + 1: 'ETH', + 100: 'xDAI', + 137: 'POL', + 10: 'ETH', + 42161: 'ETH', + 56: 'BNB', + 8453: 'ETH', +}; + +// Helper: Detect if a tx is a native fee step +export const isNativeFeeTx = ( + tx: StepTransaction, + feeReceiver: string +): boolean => { + return ( + typeof tx.to === 'string' && + typeof feeReceiver === 'string' && + tx.to.toLowerCase() === feeReceiver.toLowerCase() + ); +}; + +// Helper: Detect if a tx is an ERC20 (stablecoin or wrapped) fee step +export const isERC20FeeTx = ( + tx: StepTransaction, + swapToken: Token +): boolean => { + return ( + typeof tx.to === 'string' && + typeof swapToken.contract === 'string' && + tx.to.toLowerCase() === swapToken.contract.toLowerCase() && + tx.value === BigInt(0) && + typeof tx.data === 'string' && + tx.data.startsWith('0xa9059cbb') + ); +}; + +// Helper: Extract fee amount from tx +export const getFeeAmount = ( + tx: StepTransaction, + swapToken: Token, + decimals: number +): string => { + if (tx.value && tx.data === '0x') { + // Native + return formatEther(tx.value); + } + if (isERC20FeeTx(tx, swapToken)) { + try { + const decoded = decodeFunctionData({ + abi: erc20Abi, + data: tx.data || '0x', + }); + if ( + decoded.args && + Array.isArray(decoded.args) && + decoded.args.length > 1 && + typeof decoded.args[1] === 'bigint' + ) { + return formatUnits(decoded.args[1], decimals); + } + } catch (e) { + console.warn('Failed to decode ERC20 transfer data:', e); + return '0'; + } + } + return '0'; +}; + +// Helper: Get fee symbol +export const getFeeSymbol = ( + tx: StepTransaction, + swapToken: Token, + chainId: number +): string => { + if (tx.value && tx.data === '0x') { + return NATIVE_SYMBOLS[chainId] || 'NATIVE'; + } + return swapToken.symbol; +}; + +// Extract ERC20 token balance from walletPortfolio for a given contract and chainId +export const getTokenBalanceFromPortfolio = ( + walletPortfolio: Token[] | undefined, + contract: string, + chainId: number +): string | undefined => { + if (!walletPortfolio) return undefined; + const token = walletPortfolio.find( + (t) => + t.contract.toLowerCase() === contract.toLowerCase() && + chainNameToChainIdTokensData(t.blockchain) === chainId + ); + return token ? String(token.balance) : undefined; +}; + +// Convert to wei as bigint +export const toWei = (amount: string | number, decimals = 18): bigint => { + return parseUnits(String(amount), decimals); +}; + +// Extract native token balance from walletPortfolio for a given chainId +export const getNativeBalanceFromPortfolio = ( + walletPortfolio: Token[] | undefined, + chainId: number +): string | undefined => { + if (!walletPortfolio) return undefined; + // Find the native token for the chain (by contract address) + const nativeToken = walletPortfolio.find( + (token) => + chainNameToChainIdTokensData(token.blockchain) === chainId && + isNativeToken(token.contract) + ); + return nativeToken ? String(nativeToken.balance) : undefined; +}; diff --git a/src/apps/gas-tank/utils/converters.ts b/src/apps/gas-tank/utils/converters.ts new file mode 100644 index 00000000..8ab163bb --- /dev/null +++ b/src/apps/gas-tank/utils/converters.ts @@ -0,0 +1,11 @@ +export const hasThreeZerosAfterDecimal = (num: number): boolean => { + const decimalPart = num.toString().split('.')[1] || ''; + return decimalPart.startsWith('000'); +}; + +export const formatTokenAmount = (amount?: number) => { + if (amount === undefined) return 0; + return hasThreeZerosAfterDecimal(amount) + ? amount.toFixed(8) + : amount.toFixed(4); +}; diff --git a/src/apps/gas-tank/utils/sentry.ts b/src/apps/gas-tank/utils/sentry.ts new file mode 100644 index 00000000..44bd8215 --- /dev/null +++ b/src/apps/gas-tank/utils/sentry.ts @@ -0,0 +1,244 @@ +import * as Sentry from '@sentry/react'; +import { useWalletAddress } from '@etherspot/transaction-kit'; + +// Sentry configuration for the-exchange app +export const initSentryForExchange = () => { + Sentry.setTag('app', 'gas-tank'); + Sentry.setTag('module', 'gasTank'); +}; + +// Utility to get fallback wallet address for logging +// This function should be called from within a React component context +// where the wallet address is available +export const fallbackWalletAddressForLogging = (): string => { + // This is a utility function that should be called from within React components + // The actual wallet address should be passed as a parameter or obtained via hook + // For now, return unknown as this is a fallback utility + return 'unknown_wallet_address'; +}; + +// Enhanced Sentry logging with wallet address context +export const logExchangeEvent = ( + message: string, + level: Sentry.SeverityLevel = 'info', + extra?: Record, + tags?: Record +) => { + const walletAddress = fallbackWalletAddressForLogging(); + + Sentry.withScope((scope) => { + scope.setLevel(level); + scope.setTag('wallet_address', walletAddress); + scope.setTag('app_module', 'the-exchange'); + + if (tags) { + Object.entries(tags).forEach(([key, value]) => { + scope.setTag(key, value); + }); + } + + if (extra) { + scope.setExtra('exchange_data', extra); + } + + Sentry.captureMessage(message); + }); +}; + +// Log exchange errors with wallet address +export const logExchangeError = ( + error: Error | string, + extra?: Record, + tags?: Record +) => { + const walletAddress = fallbackWalletAddressForLogging(); + + Sentry.withScope((scope) => { + scope.setLevel('error'); + scope.setTag('wallet_address', walletAddress); + scope.setTag('app_module', 'the-exchange'); + scope.setTag('error_type', 'exchange_error'); + + if (tags) { + Object.entries(tags).forEach(([key, value]) => { + scope.setTag(key, value); + }); + } + + if (extra) { + scope.setExtra('exchange_error_data', extra); + } + + if (error instanceof Error) { + Sentry.captureException(error); + } else { + Sentry.captureMessage(error, 'error'); + } + }); +}; + +// Generic operation logging function +export const logOperation = ( + operationType: string, + operation: string, + data: Record, + walletAddress?: string +) => { + Sentry.withScope((scope) => { + scope.setLevel('info'); + scope.setTag( + 'wallet_address', + walletAddress || fallbackWalletAddressForLogging() + ); + scope.setTag('app_module', 'the-exchange'); + scope.setTag('operation_type', operationType); + scope.setTag(`${operationType}_operation`, operation); + + scope.setExtra(`${operationType}_data`, data); + + Sentry.captureMessage( + `${operationType.charAt(0).toUpperCase() + operationType.slice(1)} operation: ${operation}`, + 'info' + ); + }); +}; + +// Log swap operations +export const logSwapOperation = ( + operation: string, + data: Record, + walletAddress?: string +) => { + logOperation('swap', operation, data, walletAddress); +}; + +// Log token operations +export const logTokenOperation = ( + operation: string, + tokenData: Record, + walletAddress?: string +) => { + logOperation('token', operation, tokenData, walletAddress); +}; + +// Log offer operations +export const logOfferOperation = ( + operation: string, + offerData: Record, + walletAddress?: string +) => { + logOperation('offer', operation, offerData, walletAddress); +}; + +// Log transaction operations +export const logTransactionOperation = ( + operation: string, + transactionData: Record, + walletAddress?: string +) => { + logOperation('transaction', operation, transactionData, walletAddress); +}; + +// Log user interactions +export const logUserInteraction = ( + interaction: string, + data: Record, + walletAddress?: string +) => { + Sentry.withScope((scope) => { + scope.setLevel('info'); + scope.setTag( + 'wallet_address', + walletAddress || fallbackWalletAddressForLogging() + ); + scope.setTag('app_module', 'the-exchange'); + scope.setTag('interaction_type', 'user_action'); + scope.setTag('user_interaction', interaction); + + scope.setExtra('interaction_data', data); + + Sentry.captureMessage(`User interaction: ${interaction}`, 'info'); + }); +}; + +// Log performance metrics +export const logPerformanceMetric = ( + metric: string, + value: number, + unit: string = 'ms', + walletAddress?: string +) => { + Sentry.withScope((scope) => { + scope.setLevel('info'); + scope.setTag( + 'wallet_address', + walletAddress || fallbackWalletAddressForLogging() + ); + scope.setTag('app_module', 'the-exchange'); + scope.setTag('metric_type', 'performance'); + scope.setTag('metric_name', metric); + + scope.setExtra('performance_data', { + metric, + value, + unit, + timestamp: new Date().toISOString(), + }); + + Sentry.captureMessage( + `Performance metric: ${metric} = ${value}${unit}`, + 'info' + ); + }); +}; + +// Hook to get wallet address for logging +export const useWalletAddressForLogging = () => { + const walletAddress = useWalletAddress(); + return walletAddress || 'unknown_wallet_address'; +}; + +// Note: Error boundary removed due to TypeScript/JSX compatibility issues +// Use Sentry's default error boundary or create a separate React component file + +// Transaction monitoring for exchange operations +export const startExchangeTransaction = ( + operation: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _data: Record = {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _walletAddress?: string +) => { + return Sentry.startSpan( + { + name: `exchange.${operation}`, + op: 'exchange.operation', + }, + (span) => { + // Note: Span API has changed in Sentry v10 + // Properties are set via the span context instead + return span; + } + ); +}; + +// Breadcrumb utilities for exchange +export const addExchangeBreadcrumb = ( + message: string, + category: string = 'exchange', + data?: Record, + level: Sentry.SeverityLevel = 'info' +) => { + const walletAddress = fallbackWalletAddressForLogging(); + + Sentry.addBreadcrumb({ + message, + category, + level, + data: { + ...data, + wallet_address: walletAddress, + app_module: 'the-exchange', + }, + }); +}; diff --git a/src/apps/gas-tank/utils/types.tsx b/src/apps/gas-tank/utils/types.tsx new file mode 100644 index 00000000..547049f0 --- /dev/null +++ b/src/apps/gas-tank/utils/types.tsx @@ -0,0 +1,41 @@ +import { BridgingProvider } from '@etherspot/data-utils/dist/cjs/sdk/data/constants'; +import { Route } from '@lifi/sdk'; +import { Hex } from 'viem'; + +export enum CardPosition { + SWAP = 'SWAP', + RECEIVE = 'RECEIVE', +} + +export type SwapType = { + fromAmount: number; + fromTokenAddress: string; + fromChainId: number; + fromTokenDecimals: number; + slippage?: number; + fromAccountAddress?: string; + provider?: BridgingProvider; +}; + +export type SwapOffer = { + tokenAmountToReceive: number; + offer: Route; +}; + +export type ChainType = { + chainId: number; + chainName: string; +}; + +export type StepTransaction = { + to?: string; + data?: Hex; + value?: bigint; + gasLimit?: bigint; + gasPrice?: bigint; + chainId?: number; + type?: number | string; + transactionType?: StepType; +}; + +export type StepType = 'swap' | 'cross' | 'lifi' | 'custom' | 'approval'; diff --git a/src/apps/gas-tank/utils/wrappedTokens.ts b/src/apps/gas-tank/utils/wrappedTokens.ts new file mode 100644 index 00000000..a982b246 --- /dev/null +++ b/src/apps/gas-tank/utils/wrappedTokens.ts @@ -0,0 +1,44 @@ +export const NATIVE_TOKEN_ADDRESSES = new Set([ + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + '0x0000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000001010', +]); + +// Not including XDAI below +export const WRAPPED_NATIVE_TOKEN_ADDRESSES: Record = { + // Ethereum + 1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH + // Polygon + 137: '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', // WMATIC + // Optimism + 10: '0x4200000000000000000000000000000000000006', // WETH + // Arbitrum + 42161: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', // WETH + // Base + 8453: '0x4200000000000000000000000000000000000006', // WETH + // BNB + 56: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', // WBNB +}; + +export const isWrappedToken = (tokenAddress: string, chainId: number) => { + return ( + tokenAddress.toLowerCase() === + WRAPPED_NATIVE_TOKEN_ADDRESSES[chainId]?.toLowerCase() + ); +}; + +export const isNativeToken = (address: string) => + NATIVE_TOKEN_ADDRESSES.has(address.toLowerCase()); + +export const getWrappedTokenAddressIfNative = ( + tokenAddress: string, + chainId: number +): string => { + if (isNativeToken(tokenAddress)) { + const wrappedAddress = WRAPPED_NATIVE_TOKEN_ADDRESSES[chainId]; + // Return the original token address if no wrapped version is available + // This handles cases like XDAI or other chains without wrapped versions + return wrappedAddress || tokenAddress; + } + return tokenAddress; +}; diff --git a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx index 7fdf7318..71610e12 100644 --- a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx +++ b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx @@ -51,6 +51,7 @@ import { useTransactionDebugLogger } from '../../../hooks/useTransactionDebugLog import { GasConsumptions, getAllGaslessPaymasters, + getGasTankBalance, } from '../../../services/gasless'; import { useRecordPresenceMutation } from '../../../services/pillarXApiPresence'; import { getUserOperationStatus } from '../../../services/userOpStatus'; @@ -156,7 +157,8 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { setWalletConnectPayload, } = useBottomMenuModal(); const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; - const [isPaymaster, setIsPaymaster] = React.useState(false); + const gasTankPaymasterUrl = `${paymasterUrl}/gasTankPaymaster`; + const [isPaymaster, setIsPaymaster] = React.useState(true); const [paymasterContext, setPaymasterContext] = React.useState<{ mode: string; token?: string; @@ -178,7 +180,8 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { const [gasPrice, setGasPrice] = React.useState(); const [feeMin, setFeeMin] = React.useState(); const [selectedFeeType, setSelectedFeeType] = - React.useState('Gasless'); + React.useState('Native Token'); + const [gasTankBalance, setGasTankBalance] = React.useState(0); const dispatch = useAppDispatch(); const walletPortfolio = useAppSelector( @@ -216,12 +219,6 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { ]); const feeTypeOptions = [ - { - id: 'Gasless', - title: 'Gasless', - type: 'token', - value: '', - }, { id: 'Native Token', title: 'Native Token', @@ -236,6 +233,34 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { if (!walletPortfolio) return; const tokens = convertPortfolioAPIResponseToToken(walletPortfolio); if (!selectedAsset) return; + getGasTankBalance(accountAddress ?? '').then((res) => { + feeTypeOptions.push({ + id: 'GasTankPaymaster', + title: 'Gas Tank Paymaster', + type: 'token', + value: '', + }); + if (res) { + setGasTankBalance(res); + if (res > 0) { + feeTypeOptions.reverse(); + setFeeType(feeTypeOptions); + setIsPaymaster(true); + setPaymasterContext({ + mode: 'gasTankPaymaster', + }); + setSelectedFeeType('Gas Tank Paymaster'); + } else { + setIsPaymaster(false); + setPaymasterContext(null); + setSelectedFeeType('Native Token'); + } + } else { + setIsPaymaster(false); + setPaymasterContext(null); + setSelectedFeeType('Native Token'); + } + }); setQueryString(`?chainId=${selectedAsset.chainId}`); getAllGaslessPaymasters(selectedAsset.chainId, tokens).then( (paymasterObject) => { @@ -296,25 +321,31 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { tokenPrice: feeOptions[0].asset.price?.toString(), balance: feeOptions[0].value?.toString(), }); + feeTypeOptions.push({ + id: 'Gasless', + title: 'Gasless', + type: 'token', + value: '', + }); setSelectedPaymasterAddress(feeOptions[0].id.split('-')[2]); - if (selectedFeeType === 'Gasless') { + if (selectedFeeType === 'Native Token') { setPaymasterContext({ mode: 'commonerc20', token: feeOptions[0].asset.contract, }); setIsPaymaster(true); + feeTypeOptions.reverse(); } + setFeeType(feeTypeOptions); } else { setIsPaymaster(false); setPaymasterContext(null); setFeeAssetOptions([]); - setFeeType([]); } } else { setPaymasterContext(null); setIsPaymaster(false); setFeeAssetOptions([]); - setFeeType([]); } } ); @@ -1281,6 +1312,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { }; const handleOnChangeFeeAsset = (value: SelectOption) => { + console.log('handleOnChangeFeeAsset', value, selectedFeeType); setSelectedFeeType(value.title); if (value.title === 'Gasless') { setPaymasterContext({ @@ -1288,6 +1320,11 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { token: selectedFeeAsset?.token, }); setIsPaymaster(true); + } else if (value.title === 'Gas Tank Paymaster') { + setPaymasterContext({ + mode: 'gasTankPaymaster', + }); + setIsPaymaster(true); } else { setPaymasterContext(null); setIsPaymaster(false); @@ -1333,7 +1370,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { paymaster={ isPaymaster ? { - url: `${paymasterUrl}${queryString}`, + url: `${selectedFeeType === 'Gasless' ? paymasterUrl : gasTankPaymasterUrl}${queryString}`, context: paymasterContext, } : undefined @@ -1343,6 +1380,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { {isPaymaster && selectedPaymasterAddress && + selectedFeeType === 'Gasless' && selectedFeeAsset && ( { /> - {feeType.length > 0 && feeAssetOptions.length > 0 && ( + {feeType.length > 0 && isPaymaster && ( <> {paymasterContext?.mode === 'commonerc20' && @@ -1550,7 +1595,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { paymaster={ isPaymaster ? { - url: `${paymasterUrl}${queryString}`, + url: `${selectedFeeType === 'Gasless' ? paymasterUrl : gasTankPaymasterUrl}${queryString}`, context: paymasterContext, } : undefined @@ -1560,6 +1605,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { {isPaymaster && selectedPaymasterAddress && + selectedFeeType === 'Gasless' && selectedFeeAsset && approveData && ( => { + try { + const response = await fetch( + `http://localhost:5000/pillarx-staging/us-central1/paymaster/getGasTankBalance?sender=${walletAddress}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch gas tank balance: ${response.status}`); + } + + const data = await response.json(); + const balances: ChainBalance[] = Object.values(data.balance || {}); + // Get cumalative balance for all chains + const total = balances.reduce((sum, chainBalance) => { + const balance = parseFloat(chainBalance.balance) || 0; + return sum + balance; + }, 0); + return total; + } catch (error) { + console.error('Error fetching gas tank balance:', error); + return null; + } +}; From e03e1d9920c079a8f693d1de1ea673232d6cd576 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Tue, 26 Aug 2025 12:39:15 +0530 Subject: [PATCH 02/20] added sentry init --- src/apps/gas-tank/components/TopUpModal.tsx | 33 ++- src/apps/gas-tank/index.tsx | 216 ++++++++++++++++++++ src/apps/gas-tank/utils/sentry.ts | 16 +- 3 files changed, 251 insertions(+), 14 deletions(-) diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx index a02c38bf..87076c1d 100644 --- a/src/apps/gas-tank/components/TopUpModal.tsx +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -17,7 +17,7 @@ import { PortfolioToken, chainNameToChainIdTokensData } from '../../../services/ import useGlobalTransactionsBatch from '../../../hooks/useGlobalTransactionsBatch'; import useBottomMenuModal from '../../../hooks/useBottomMenuModal'; import { useAppDispatch, useAppSelector } from '../hooks/useReducerHooks'; -import useOffer from '../hooks/useOffer'; +import useOffer, { USDC_ADDRESSES } from '../hooks/useOffer'; // redux import { setWalletPortfolio } from '../reducer/gasTankSlice'; @@ -44,6 +44,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const [selectedToken, setSelectedToken] = useState( null ); + const [errorMsg, setErrorMsg] = useState(null); const [amount, setAmount] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [portfolioTokens, setPortfolioTokens] = useState([]); @@ -84,14 +85,23 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { walletPortfolioDataError, ]); + useEffect(() => { + setErrorMsg(null); + }, [selectedToken]); + const handleTopUp = async () => { if (!selectedToken || !amount || !walletAddress) return; + if (USDC_ADDRESSES[chainNameToChainIdTokensData(selectedToken.blockchain)] === undefined) { + setErrorMsg('Gas Tank is not supported on the selected token\'s chain.'); + return; + } + setIsProcessing(true); try { // Check if token is USDC - const isUSDC = selectedToken.symbol.toUpperCase() === 'USDC'; + const isUSDC = selectedToken.contract === USDC_ADDRESSES[chainNameToChainIdTokensData(selectedToken.blockchain)]; let receiveSwapAmount = amount; @@ -106,6 +116,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { slippage: 0.03, }); if (!bestOffer) { + setErrorMsg('No best offer found for the swap. Please try a different token or amount.'); console.warn('No best offer found for swap'); return; } @@ -136,7 +147,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { addToBatch({ title: `Swap to USDC ${index + 1}/${swapTransactions.length}`, description: `Convert ${amount} ${selectedToken.symbol} to USDC for Gas Tank`, - to: tx.to, + to: tx.to || '', value: integerValue, data: tx.data, chainId: chainNameToChainIdTokensData(selectedToken.blockchain), @@ -148,6 +159,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { console.warn( 'Failed to get swap route. Please try a different token or amount.' ); + setErrorMsg('Failed to get swap route. Please try a different token or amount.'); setIsProcessing(false); return; } @@ -165,7 +177,11 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { ); if (!response.ok) { - throw new Error('Failed to get deposit transaction'); + const errorText = await response.text(); + console.error('Error fetching transaction data:', errorText); + setErrorMsg('Failed to fetch transaction data. Please try again with different token or amount.'); + setIsProcessing(false); + return; } const transactionData = await response.json(); @@ -173,7 +189,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { // Add transactions to batch if (Array.isArray(transactionData.result)) { - transactionData.result.forEach((tx, index) => { + transactionData.result.forEach((tx: {value?: string, to: string, data?: string}, index: number) => { const value = tx.value || '0'; // Handle bigint conversion properly let bigIntValue: bigint; @@ -238,6 +254,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { }; const handleAmountChange = (value: string) => { + setErrorMsg(null); // Only allow numeric input if (value === '' || /^\d*\.?\d*$/.test(value)) { setAmount(value); @@ -246,6 +263,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const getMaxAmount = () => { if (!selectedToken) return '0'; + setErrorMsg(null); return formatTokenAmount(selectedToken.balance); }; @@ -316,7 +334,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { value={amount} onChange={(e) => handleAmountChange(e.target.value)} /> - setAmount(getMaxAmount())}> + setAmount(getMaxAmount() || '')}> MAX @@ -333,6 +351,9 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { } > {(() => { + if (errorMsg) { + return errorMsg; + } if (isProcessing) { return ( <> diff --git a/src/apps/gas-tank/index.tsx b/src/apps/gas-tank/index.tsx index e1ca5fb7..20748d45 100644 --- a/src/apps/gas-tank/index.tsx +++ b/src/apps/gas-tank/index.tsx @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ +import { useEtherspot, useWalletAddress } from '@etherspot/transaction-kit'; import styled from 'styled-components'; +import { useEffect, useRef } from 'react'; +import { createConfig, EVM } from '@lifi/sdk'; // styles import './styles/gasTank.css'; @@ -7,7 +10,220 @@ import './styles/gasTank.css'; // components import GasTank from './components/GasTank'; +// utils +import { supportedChains } from '../../utils/blockchain'; +import { addExchangeBreadcrumb, initSentryForGasTank, logExchangeEvent } from './utils/sentry'; + export const App = () => { + const { provider } = useEtherspot(); + const walletAddress = useWalletAddress(); + + // Use ref to track if config has been initialized + const configInitialized = useRef(false); + useEffect(() => { + initSentryForGasTank(); + + // Log app initialization + logExchangeEvent( + 'The Exchange app initialized', + 'info', + { + walletAddress, + }, + { + component: 'App', + action: 'initialization', + } + ); + + addExchangeBreadcrumb('The Exchange app loaded', 'app', { + walletAddress, + timestamp: new Date().toISOString(), + }); + }, [walletAddress]); + + /** + * Initialize LiFi SDK configuration + * This sets up the LiFi SDK with the wallet provider and chain switching capabilities + * Only runs once when the provider is available to avoid multiple initializations + */ + useEffect(() => { + if (!provider || configInitialized.current) { + return; + } + + try { + /** + * Create LiFi configuration with: + * - Integrator name for tracking + * - EVM provider with wallet client and chain switching + * - API key for LiFi services + */ + createConfig({ + integrator: 'PillarX', + providers: [ + EVM({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getWalletClient: async () => provider as any, + + /** + * Chain switching functionality + * Handles switching between different blockchain networks + * Implements EIP-1193 standard for wallet chain switching + */ + switchChain: async (chainId) => { + // Log chain switching initiation + logExchangeEvent( + 'Chain switching initiated', + 'info', + { + walletAddress, + chainId, + currentChain: supportedChains.find( + (chain) => chain.id === chainId + ), + }, + { + component: 'App', + action: 'chain_switch', + } + ); + + try { + /** + * Step 1: Validate chain support + * Check if the requested chain is supported by our application + */ + const targetChain = supportedChains.find( + (chain) => chain.id === chainId + ); + + if (!targetChain) { + throw new Error(`Chain ${chainId} is not supported`); + } + + /** + * Step 2: Request EIP-1193 chain switch on the underlying provider + * This uses the standard Ethereum wallet interface to switch chains + */ + const providerWithRequest = provider as { + request?: (args: { + method: string; + params: unknown[]; + }) => Promise; + }; + + if (providerWithRequest.request) { + try { + /** + * Attempt to switch to the target chain + * Uses wallet_switchEthereumChain method + */ + await providerWithRequest.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${chainId.toString(16)}` }], + }); + } catch (switchError: unknown) { + /** + * Step 3: Handle chain not found (error code 4902) + * If the chain is not added to the wallet, try to add it + * This provides a seamless user experience + */ + if ((switchError as { code?: number }).code === 4902) { + await providerWithRequest.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId: `0x${chainId.toString(16)}`, + chainName: targetChain.name, + nativeCurrency: targetChain.nativeCurrency, + rpcUrls: targetChain.rpcUrls.default.http, + blockExplorerUrls: targetChain.blockExplorers + ?.default?.url + ? [targetChain.blockExplorers.default.url] + : undefined, + }, + ], + }); + } else { + throw switchError; + } + } + } + + /** + * Step 4: Log successful chain switch + * Record the successful chain switch for monitoring and debugging + */ + logExchangeEvent( + 'Chain switching completed', + 'info', + { + walletAddress, + chainId, + newChain: targetChain, + }, + { + component: 'App', + action: 'chain_switch_success', + } + ); + + /** + * Step 5: Return the provider for LiFi SDK + * The LiFi SDK expects a specific client type, so we cast accordingly + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return provider as any; + } catch (error) { + /** + * Error handling for chain switching failures + * Log the error and re-throw for proper error handling + */ + logExchangeEvent( + 'Chain switching failed', + 'error', + { + walletAddress, + chainId, + error: + error instanceof Error ? error.message : String(error), + }, + { + component: 'App', + action: 'chain_switch_error', + } + ); + throw error; + } + }, + }), + ], + apiKey: import.meta.env.VITE_LIFI_API_KEY, + }); + + // Mark config as initialized to prevent re-initialization + configInitialized.current = true; + } catch (error) { + /** + * Error handling for LiFi config initialization + * Log the error and continue with app functionality + */ + console.error('Failed to initialize LiFi config:', error); + logExchangeEvent( + 'LiFi config initialization failed', + 'error', + { + walletAddress, + error: error instanceof Error ? error.message : String(error), + }, + { + component: 'App', + action: 'config_init_error', + } + ); + } + }, [provider, walletAddress]); return ( diff --git a/src/apps/gas-tank/utils/sentry.ts b/src/apps/gas-tank/utils/sentry.ts index 44bd8215..01be94e7 100644 --- a/src/apps/gas-tank/utils/sentry.ts +++ b/src/apps/gas-tank/utils/sentry.ts @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/react'; import { useWalletAddress } from '@etherspot/transaction-kit'; -// Sentry configuration for the-exchange app -export const initSentryForExchange = () => { +// Sentry configuration for gas-tank app +export const initSentryForGasTank = () => { Sentry.setTag('app', 'gas-tank'); Sentry.setTag('module', 'gasTank'); }; @@ -29,7 +29,7 @@ export const logExchangeEvent = ( Sentry.withScope((scope) => { scope.setLevel(level); scope.setTag('wallet_address', walletAddress); - scope.setTag('app_module', 'the-exchange'); + scope.setTag('app_module', 'gasTank'); if (tags) { Object.entries(tags).forEach(([key, value]) => { @@ -38,7 +38,7 @@ export const logExchangeEvent = ( } if (extra) { - scope.setExtra('exchange_data', extra); + scope.setExtra('extraData: ', extra); } Sentry.captureMessage(message); @@ -56,7 +56,7 @@ export const logExchangeError = ( Sentry.withScope((scope) => { scope.setLevel('error'); scope.setTag('wallet_address', walletAddress); - scope.setTag('app_module', 'the-exchange'); + scope.setTag('app_module', 'gasTank'); scope.setTag('error_type', 'exchange_error'); if (tags) { @@ -90,7 +90,7 @@ export const logOperation = ( 'wallet_address', walletAddress || fallbackWalletAddressForLogging() ); - scope.setTag('app_module', 'the-exchange'); + scope.setTag('app_module', 'gasTank'); scope.setTag('operation_type', operationType); scope.setTag(`${operationType}_operation`, operation); @@ -151,7 +151,7 @@ export const logUserInteraction = ( 'wallet_address', walletAddress || fallbackWalletAddressForLogging() ); - scope.setTag('app_module', 'the-exchange'); + scope.setTag('app_module', 'gasTank'); scope.setTag('interaction_type', 'user_action'); scope.setTag('user_interaction', interaction); @@ -238,7 +238,7 @@ export const addExchangeBreadcrumb = ( data: { ...data, wallet_address: walletAddress, - app_module: 'the-exchange', + app_module: 'gasTank', }, }); }; From 3b948a8e7564187fda7e93bdefea1e7d442ec169 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Tue, 26 Aug 2025 12:54:06 +0530 Subject: [PATCH 03/20] bug fix --- src/apps/gas-tank/hooks/useOffer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/gas-tank/hooks/useOffer.tsx b/src/apps/gas-tank/hooks/useOffer.tsx index 536c5379..cc23bc62 100644 --- a/src/apps/gas-tank/hooks/useOffer.tsx +++ b/src/apps/gas-tank/hooks/useOffer.tsx @@ -42,7 +42,7 @@ import { startExchangeTransaction, } from '../utils/sentry'; -const USDC_ADDRESSES: { [chainId: number]: string } = { +export const USDC_ADDRESSES: { [chainId: number]: string } = { 137: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon 42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum 10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism From 614d3f962412f03e330d28292d5171cb728c21a3 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Tue, 26 Aug 2025 13:05:04 +0530 Subject: [PATCH 04/20] added gas-tank app --- src/providers/AllowedAppsProvider.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/providers/AllowedAppsProvider.tsx b/src/providers/AllowedAppsProvider.tsx index 4855cff9..1faed0cc 100644 --- a/src/providers/AllowedAppsProvider.tsx +++ b/src/providers/AllowedAppsProvider.tsx @@ -53,7 +53,13 @@ const AllowedAppsProvider = ({ children }: { children: React.ReactNode }) => { setIsLoading(false); return; } - setAllowed(data?.map((app: ApiAllowedApp) => app.appId)); + const allowedAppIds = + data?.map((app: ApiAllowedApp) => app.appId) || []; + + // Add gas-tank app for development + allowedAppIds.push('gas-tank'); + + setAllowed(allowedAppIds); } catch (e) { console.warn('Error calling PillarX apps API', e); } From 379171b44207b07cb555574bff566b139846e7fa Mon Sep 17 00:00:00 2001 From: Vignesh Date: Tue, 26 Aug 2025 13:27:28 +0530 Subject: [PATCH 05/20] bug fix --- src/apps/gas-tank/components/TopUpModal.tsx | 17 ++++++++++++++--- src/apps/gas-tank/index.tsx | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx index 87076c1d..2e5e02cd 100644 --- a/src/apps/gas-tank/components/TopUpModal.tsx +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -17,7 +17,7 @@ import { PortfolioToken, chainNameToChainIdTokensData } from '../../../services/ import useGlobalTransactionsBatch from '../../../hooks/useGlobalTransactionsBatch'; import useBottomMenuModal from '../../../hooks/useBottomMenuModal'; import { useAppDispatch, useAppSelector } from '../hooks/useReducerHooks'; -import useOffer, { USDC_ADDRESSES } from '../hooks/useOffer'; +import useOffer from '../hooks/useOffer'; // redux import { setWalletPortfolio } from '../reducer/gasTankSlice'; @@ -34,6 +34,13 @@ interface TopUpModalProps { onSuccess?: () => void; } +const USDC_ADDRESSES: { [chainId: number]: string } = { + 137: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon + 42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum + 10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism + // Add more chains as needed +}; + const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const paymasterUrl = import.meta.env.VITE_PAYMASTER_URL; const walletAddress = useWalletAddress(); @@ -263,10 +270,14 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const getMaxAmount = () => { if (!selectedToken) return '0'; - setErrorMsg(null); return formatTokenAmount(selectedToken.balance); }; + const handleMaxClick = () => { + setErrorMsg(null); + setAmount(getMaxAmount() || ''); + }; + if (!isOpen) return null; return ( @@ -334,7 +345,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { value={amount} onChange={(e) => handleAmountChange(e.target.value)} /> - setAmount(getMaxAmount() || '')}> + MAX diff --git a/src/apps/gas-tank/index.tsx b/src/apps/gas-tank/index.tsx index 20748d45..73177dce 100644 --- a/src/apps/gas-tank/index.tsx +++ b/src/apps/gas-tank/index.tsx @@ -25,7 +25,7 @@ export const App = () => { // Log app initialization logExchangeEvent( - 'The Exchange app initialized', + 'Gas Tank app initialized', 'info', { walletAddress, @@ -36,7 +36,7 @@ export const App = () => { } ); - addExchangeBreadcrumb('The Exchange app loaded', 'app', { + addExchangeBreadcrumb('Gas Tank app loaded', 'app', { walletAddress, timestamp: new Date().toISOString(), }); From 0f9902eff562ce5c711f719ed1c095874abd8908 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Tue, 26 Aug 2025 13:41:22 +0530 Subject: [PATCH 06/20] comparison change --- src/apps/gas-tank/components/TopUpModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx index 2e5e02cd..e9792aab 100644 --- a/src/apps/gas-tank/components/TopUpModal.tsx +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -108,7 +108,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { try { // Check if token is USDC - const isUSDC = selectedToken.contract === USDC_ADDRESSES[chainNameToChainIdTokensData(selectedToken.blockchain)]; + const isUSDC = selectedToken.contract.toLowerCase() === USDC_ADDRESSES[chainNameToChainIdTokensData(selectedToken.blockchain)].toLowerCase(); let receiveSwapAmount = amount; From 7b267f8765530a30d5a3f070a85bf0087a7f8420 Mon Sep 17 00:00:00 2001 From: Vignesh Date: Tue, 26 Aug 2025 13:46:24 +0530 Subject: [PATCH 07/20] added configurable url --- src/services/gasless.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/gasless.ts b/src/services/gasless.ts index 7058b36b..d5cd84d6 100644 --- a/src/services/gasless.ts +++ b/src/services/gasless.ts @@ -65,7 +65,7 @@ export const getGasTankBalance = async ( ): Promise => { try { const response = await fetch( - `http://localhost:5000/pillarx-staging/us-central1/paymaster/getGasTankBalance?sender=${walletAddress}`, + `${import.meta.env.VITE_PAYMASTER_URL}/getGasTankBalance?sender=${walletAddress}`, { method: 'GET', headers: { From fdbb3baac53712faad4a92e33ec0635d7c23e87e Mon Sep 17 00:00:00 2001 From: Vignesh Date: Mon, 1 Sep 2025 16:54:48 +0530 Subject: [PATCH 08/20] changes according to feedback --- AGENT.md | 29 +++++++++++++++ .../gas-tank/components/GasTankHistory.tsx | 2 +- src/apps/gas-tank/components/TopUpModal.tsx | 36 +++++++++++++++++-- .../gas-tank/components/UniversalGasTank.tsx | 8 ++--- src/apps/gas-tank/hooks/useGasTankBalance.tsx | 11 +++++- src/apps/gas-tank/hooks/useOffer.tsx | 18 ++++++++-- src/apps/gas-tank/styles/gasTank.css | 4 +++ src/apps/gas-tank/utils/sentry.ts | 11 +++--- .../SendModal/SendModalTokensTabView.tsx | 26 ++++++++------ 9 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000..1a8b27d8 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,29 @@ +# AGENT.md - PillarX Development Guide + +## Build/Test Commands +- `npm run test` - Run tests with Vitest +- `npm run test:watch` - Run tests in watch mode (after linting) +- `npm run test:ci` - Run tests with coverage for CI +- `npm run test:update` - Update test snapshots +- `npm run build` - Build for production +- `npm run dev` - Start development server +- `npm run lint` - Run ESLint +- `npm run lint:fix` - Auto-fix linting issues +- `npm run format` - Format code with Prettier + +## Architecture & Structure +- **Multi-app platform**: Core PillarX with multiple sub-apps in `src/apps/` +- **Active apps**: pillarx-app, the-exchange, token-atlas, deposit, leaderboard +- **Testing**: Vitest + React Testing Library, tests in `__tests__/` or `test/` directories +- **State**: Redux Toolkit (`src/store.ts`) + React Query for server state +- **Styling**: Tailwind CSS + styled-components + Material-UI Joy components +- **Blockchain**: Ethers v5, Viem, multiple wallet connectors (Privy, Reown/WalletConnect) + +## Code Style & Conventions +- **Linting**: Airbnb TypeScript + Prettier integration +- **Imports**: Absolute imports, TypeScript strict mode, no file extensions +- **Components**: Arrow functions or function declarations, no React imports needed (JSX runtime) +- **Quotes**: Single quotes, trailing commas (ES5), 80 char width, 2 space tabs +- **Error handling**: Console.warn/error allowed, no console.log in production +- **Files**: .tsx for React components, .ts for utilities +- **Naming**: camelCase for variables/functions, PascalCase for components diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index 688f7804..4d3294ce 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -74,7 +74,7 @@ const GasTankHistory = () => { }, ]); - const sortedHistory = historyData.sort( + const sortedHistory = [...historyData].sort( (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() ); diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx index e9792aab..55f6f209 100644 --- a/src/apps/gas-tank/components/TopUpModal.tsx +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -3,7 +3,7 @@ import { useWalletAddress } from '@etherspot/transaction-kit'; import { CircularProgress } from '@mui/material'; import { useState, useEffect } from 'react'; import { BigNumber } from 'ethers'; -import { formatEther } from 'viem'; +import { formatEther, isAddress } from 'viem'; import styled from 'styled-components'; // services @@ -27,6 +27,8 @@ import { formatTokenAmount } from '../utils/converters'; // types import { PortfolioData } from '../../../types/api'; +import { logExchangeError, logExchangeEvent } from '../utils/sentry'; +import { useTransactionDebugLogger } from '../../../hooks/useTransactionDebugLogger'; interface TopUpModalProps { isOpen: boolean; @@ -55,6 +57,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const [amount, setAmount] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [portfolioTokens, setPortfolioTokens] = useState([]); + const { transactionDebugLog } = useTransactionDebugLogger(); const walletPortfolio = useAppSelector( (state) => state.swap.walletPortfolio as PortfolioData | undefined @@ -81,6 +84,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { } if (!isWalletPortfolioDataSuccess || walletPortfolioDataError) { if (walletPortfolioDataError) { + logExchangeError('Failed to fetch wallet portfolio', { "error": walletPortfolioDataError }, { component: 'TopUpModal', action: 'failed_to_fetch_wallet_portfolio' }); console.error(walletPortfolioDataError); } dispatch(setWalletPortfolio(undefined)); @@ -103,6 +107,20 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { setErrorMsg('Gas Tank is not supported on the selected token\'s chain.'); return; } + // Validate paymaster URL + if (!paymasterUrl) { + setErrorMsg('Service unavailable. Please try again later.'); + return; + } + + // Validate amount + const n = Number(amount); + if (!Number.isFinite(n) || n <= 0 || n > (selectedToken.balance ?? 0)) { + setErrorMsg('Enter a valid amount within your balance.'); + return; + } + // Reset error if valid + setErrorMsg(null); setIsProcessing(true); @@ -123,6 +141,8 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { slippage: 0.03, }); if (!bestOffer) { + logExchangeError('No best offer found for swap', {}, { component: 'TopUpModal', action: 'no_best_offer_found' }); + setIsProcessing(false); setErrorMsg('No best offer found for the swap. Please try a different token or amount.'); console.warn('No best offer found for swap'); return; @@ -151,10 +171,16 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { } const integerValue = formatEther(bigIntValue); + if (!tx.to || !isAddress(tx.to)) { + setErrorMsg('Invalid transaction target for swap route. Please try again.'); + logExchangeEvent('Invalid tx.to in swap step', 'error', { tx }, { component: 'TopUpModal', action: 'invalid_tx_to' }); + setIsProcessing(false); + return; + } addToBatch({ title: `Swap to USDC ${index + 1}/${swapTransactions.length}`, description: `Convert ${amount} ${selectedToken.symbol} to USDC for Gas Tank`, - to: tx.to || '', + to: tx.to, value: integerValue, data: tx.data, chainId: chainNameToChainIdTokensData(selectedToken.blockchain), @@ -166,6 +192,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { console.warn( 'Failed to get swap route. Please try a different token or amount.' ); + logExchangeError('Failed to get swap route', { "error": swapError }, { component: 'TopUpModal', action: 'failed_to_get_swap_route' }); setErrorMsg('Failed to get swap route. Please try a different token or amount.'); setIsProcessing(false); return; @@ -186,13 +213,14 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { if (!response.ok) { const errorText = await response.text(); console.error('Error fetching transaction data:', errorText); + logExchangeError('Failed to fetch transaction data', { "error": errorText }, { component: 'TopUpModal', action: 'failed_to_fetch_transaction_data' }); setErrorMsg('Failed to fetch transaction data. Please try again with different token or amount.'); setIsProcessing(false); return; } const transactionData = await response.json(); - console.log('Transaction data:', transactionData); + transactionDebugLog('Gas Tank Top-up transaction data', transactionData); // Add transactions to batch if (Array.isArray(transactionData.result)) { @@ -255,6 +283,8 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { } catch (error) { console.error('Error processing top-up:', error); console.warn('Failed to process top-up. Please try again.'); + logExchangeError('Failed to process top-up', { "error": error }, { component: 'TopUpModal', action: 'failed_to_process_top_up' }); + setErrorMsg('Failed to process top-up. Please try again.'); } finally { setIsProcessing(false); } diff --git a/src/apps/gas-tank/components/UniversalGasTank.tsx b/src/apps/gas-tank/components/UniversalGasTank.tsx index d4d780fe..b29703a9 100644 --- a/src/apps/gas-tank/components/UniversalGasTank.tsx +++ b/src/apps/gas-tank/components/UniversalGasTank.tsx @@ -41,16 +41,16 @@ const UniversalGasTank = () => { {(() => { if (isBalanceLoading) { return ( - - + + Loading balance... - + ); } if (balanceError) { return ( - Error loading balance + Sorry, we had an issue loading your balance. Try pressing the retry button. Retry ); diff --git a/src/apps/gas-tank/hooks/useGasTankBalance.tsx b/src/apps/gas-tank/hooks/useGasTankBalance.tsx index ce125529..a483c0cc 100644 --- a/src/apps/gas-tank/hooks/useGasTankBalance.tsx +++ b/src/apps/gas-tank/hooks/useGasTankBalance.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useWalletAddress } from '@etherspot/transaction-kit'; +import { logExchangeError } from '../utils/sentry'; interface ChainBalance { chainId: string; @@ -30,11 +31,18 @@ const useGasTankBalance = (): UseGasTankBalanceReturn => { const fetchGasTankBalance = useCallback(async () => { if (!walletAddress) { + setError(null); setTotalBalance(0); setChainBalances([]); return; } + if (!paymasterUrl) { + setError('Paymaster URL is not configured'); + setTotalBalance(0); + setChainBalances([]); + return; + } setIsLoading(true); setError(null); @@ -70,6 +78,7 @@ const useGasTankBalance = (): UseGasTankBalanceReturn => { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; setError(errorMessage); + logExchangeError(errorMessage, { "error": err }, { component: 'useGasTankBalance', action: 'failed_to_fetch_gas_tank_balance' }); console.error('Error fetching gas tank balance:', err); // Set default values on error @@ -78,7 +87,7 @@ const useGasTankBalance = (): UseGasTankBalanceReturn => { } finally { setIsLoading(false); } - }, [walletAddress]); + }, [walletAddress, paymasterUrl]); // Initial fetch and when wallet address changes useEffect(() => { diff --git a/src/apps/gas-tank/hooks/useOffer.tsx b/src/apps/gas-tank/hooks/useOffer.tsx index cc23bc62..7c88d8dd 100644 --- a/src/apps/gas-tank/hooks/useOffer.tsx +++ b/src/apps/gas-tank/hooks/useOffer.tsx @@ -39,6 +39,7 @@ import { } from '../utils/wrappedTokens'; import { addExchangeBreadcrumb, + logExchangeError, startExchangeTransaction, } from '../utils/sentry'; @@ -113,6 +114,7 @@ const useOffer = () => { return undefined; } catch (e) { console.error('Failed to get native fee estimation via LiFi:', e); + logExchangeError('Failed to get native fee estimation via LiFi', { "error": e }, { component: 'useOffer', action: 'failed_to_get_native_fee_estimation' }); return undefined; } }; @@ -200,6 +202,7 @@ const useOffer = () => { 'Sorry, an error occurred while trying to fetch the best swap offer. Please try again.', e ); + logExchangeError('Failed to get best swap offer via LiFi', { "error": e }, { component: 'useOffer', action: 'failed_to_get_best_swap_offer' }); // Return undefined instead of empty object on error return undefined; } @@ -248,6 +251,7 @@ const useOffer = () => { return allowance === BigInt(0) ? undefined : allowance; } catch (error) { console.error('Failed to check token allowance:', error); + logExchangeError('Failed to check token allowance', { "error": error }, { component: 'useOffer', action: 'failed_to_check_token_allowance' }); return undefined; } }; @@ -274,10 +278,13 @@ const useOffer = () => { route.fromToken.chainId ); - // Convert fromAmount (number) to BigInt using token decimals + // Convert fromAmount (number) to BigInt using the correct token decimals + const decimals = typeof route.fromToken.decimals === 'number' && route.fromToken.decimals > 0 + ? route.fromToken.decimals + : 18; // fallback to 18 if undefined or invalid const fromAmountBigInt = parseUnits( String(fromAmount), - 6 // Assuming USDC has 6 decimals + decimals ); /** @@ -288,6 +295,7 @@ const useOffer = () => { // Validate fee receiver address if (!feeReceiver) { + logExchangeError('Fee receiver address is not configured', { "error": 'Fee receiver address is not configured' }, { component: 'useOffer', action: 'fee_receiver_address_not_configured' }); throw new Error('Fee receiver address is not configured'); } @@ -302,6 +310,7 @@ const useOffer = () => { getNativeBalanceFromPortfolio(userPortfolio, fromTokenChainId) || '0'; userNativeBalance = toWei(nativeBalance, 18); } catch (e) { + logExchangeError('Failed to fetch balances for swap', { "error": e }, { component: 'useOffer', action: 'failed_to_fetch_balances_for_swap' }); throw new Error('Unable to fetch balances for swap.'); } @@ -482,6 +491,11 @@ const useOffer = () => { }); } } catch (error) { + logExchangeError( + 'Failed to get step transactions:', + { "error": error }, + { component: 'useOffer', action: 'failed_to_get_step_transactions' } + ); console.error('Failed to get step transactions:', error); throw error; // Re-throw so the UI can handle it } diff --git a/src/apps/gas-tank/styles/gasTank.css b/src/apps/gas-tank/styles/gasTank.css index b4585e72..2816df49 100644 --- a/src/apps/gas-tank/styles/gasTank.css +++ b/src/apps/gas-tank/styles/gasTank.css @@ -11,3 +11,7 @@ padding: 16px; } } + +.gas-tank-loading-spinner { + color: #8b5cf6 !important; +} \ No newline at end of file diff --git a/src/apps/gas-tank/utils/sentry.ts b/src/apps/gas-tank/utils/sentry.ts index 01be94e7..dc289c3b 100644 --- a/src/apps/gas-tank/utils/sentry.ts +++ b/src/apps/gas-tank/utils/sentry.ts @@ -7,14 +7,17 @@ export const initSentryForGasTank = () => { Sentry.setTag('module', 'gasTank'); }; +let globalWalletAddress: string | null = null; + +export const setGlobalWalletAddress = (address: string) => { + globalWalletAddress = address; +}; + // Utility to get fallback wallet address for logging // This function should be called from within a React component context // where the wallet address is available export const fallbackWalletAddressForLogging = (): string => { - // This is a utility function that should be called from within React components - // The actual wallet address should be passed as a parameter or obtained via hook - // For now, return unknown as this is a fallback utility - return 'unknown_wallet_address'; + return globalWalletAddress || 'unknown_wallet_address'; }; // Enhanced Sentry logging with wallet address context diff --git a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx index 71610e12..87e28e29 100644 --- a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx +++ b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx @@ -182,6 +182,9 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { const [selectedFeeType, setSelectedFeeType] = React.useState('Native Token'); const [gasTankBalance, setGasTankBalance] = React.useState(0); + const [fetchingBalances, setFetchingBalances] = React.useState(false); + const [defaultSelectedFeeTypeId, setDefaultSelectedFeeTypeId] = + React.useState('Gas Tank Paymaster'); const dispatch = useAppDispatch(); const walletPortfolio = useAppSelector( @@ -233,9 +236,10 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { if (!walletPortfolio) return; const tokens = convertPortfolioAPIResponseToToken(walletPortfolio); if (!selectedAsset) return; + setFetchingBalances(true); getGasTankBalance(accountAddress ?? '').then((res) => { feeTypeOptions.push({ - id: 'GasTankPaymaster', + id: 'Gas Tank Paymaster', title: 'Gas Tank Paymaster', type: 'token', value: '', @@ -243,22 +247,26 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { if (res) { setGasTankBalance(res); if (res > 0) { - feeTypeOptions.reverse(); setFeeType(feeTypeOptions); setIsPaymaster(true); setPaymasterContext({ mode: 'gasTankPaymaster', }); setSelectedFeeType('Gas Tank Paymaster'); + setFetchingBalances(false); } else { setIsPaymaster(false); setPaymasterContext(null); setSelectedFeeType('Native Token'); + setDefaultSelectedFeeTypeId('Native Token'); + setFetchingBalances(false); } } else { setIsPaymaster(false); + setFetchingBalances(false); setPaymasterContext(null); setSelectedFeeType('Native Token'); + setDefaultSelectedFeeTypeId('Native Token'); } }); setQueryString(`?chainId=${selectedAsset.chainId}`); @@ -334,7 +342,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { token: feeOptions[0].asset.contract, }); setIsPaymaster(true); - feeTypeOptions.reverse(); + setDefaultSelectedFeeTypeId('Gasless'); } setFeeType(feeTypeOptions); } else { @@ -1403,9 +1411,10 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { options={feeType} isLoadingOptions={ feeAssetOptions.length === 0 && - selectedFeeType !== 'Gas Tank Paymaster' + fetchingBalances && + defaultSelectedFeeTypeId === selectedFeeType } - defaultSelectedId={feeType[0].id} + defaultSelectedId={defaultSelectedFeeTypeId} /> )} @@ -1543,11 +1552,8 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { type="token" onChange={handleOnChangeFeeAsset} options={feeType} - isLoadingOptions={ - feeAssetOptions.length === 0 && - selectedFeeType !== 'Gas Tank Paymaster' - } - defaultSelectedId={feeType[0].id} + isLoadingOptions={feeAssetOptions.length === 0 && fetchingBalances} + defaultSelectedId={defaultSelectedFeeTypeId} /> {paymasterContext?.mode === 'commonerc20' && feeAssetOptions.length > 0 && ( From c22f7b3201d7a73bdd3de4d10426f055206f643c Mon Sep 17 00:00:00 2001 From: vignesha22 Date: Thu, 4 Sep 2025 02:23:03 +0530 Subject: [PATCH 09/20] added gas tank history --- .../gas-tank/components/GasTankHistory.tsx | 385 +++++++++++++----- 1 file changed, 293 insertions(+), 92 deletions(-) diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index 4d3294ce..60cfcf84 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -1,7 +1,12 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import styled from 'styled-components'; +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { formatUnits } from 'viem'; +/** + * Represents a single entry in the gas tank history table. + */ interface HistoryEntry { id: string; date: string; @@ -14,108 +19,246 @@ interface HistoryEntry { }; } +/** + * Keys available for sorting the table. + */ +type SortKey = 'id' | 'date' | 'type' | 'amount' | 'token'; + +/** + * REST API base URL, configurable via environment variable. + */ +const API_URL = import.meta.env.VITE_PAYMASTER_URL || ''; + +/** + * GasTankHistory component + * Displays a sortable, scrollable table of gas tank transaction history for the connected wallet. + * Handles loading, error, and empty states. Allows manual refresh. + */ const GasTankHistory = () => { - const [historyData] = useState([ - { - id: '1', - date: 'Apr 28, 11:20', - type: 'Top-up', - amount: '+$50.00', - token: { symbol: 'ETH', value: '0.0167', icon: '🔵' }, - }, - { - id: '2', - date: 'Apr 27, 16:45', - type: 'Spend', - amount: '-$2.30 (gas)', - token: { symbol: 'USDC', value: '2.30', icon: '🔵' }, - }, - { - id: '3', - date: 'Apr 27, 16:03', - type: 'Top-up', - amount: '-$110 (gas)', - token: { symbol: 'USDC', value: '119', icon: '🔵' }, - }, - { - id: '4', - date: 'Apr 27, 16:45', - type: 'Spend', - amount: '+$47.50', - token: { symbol: 'OP', value: '30.84', icon: '🔴' }, - }, - { - id: '5', - date: 'Apr 27, 16:03', - type: 'Top-up', - amount: '+$65.00', - token: { symbol: 'ETH', value: '0.0217', icon: '🔵' }, - }, - { - id: '6', - date: 'Apr 26, 15:14', - type: 'Spend', - amount: '-$3.10 (gas)', - token: { symbol: 'USDC', value: '3.10', icon: '🔵' }, - }, - { - id: '7', - date: 'Apr 26, 15:01', - type: 'Top-up', - amount: '+$75.00', - token: { symbol: 'BNB', value: '0.0250', icon: '🟡' }, - }, - { - id: '8', - date: 'Apr 25, 14:22', - type: 'Spend', - amount: '-$5.00 (gas)', - token: { symbol: 'USDC', value: '5.00', icon: '🔵' }, - }, - ]); - - const sortedHistory = [...historyData].sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() - ); + const walletAddress = useWalletAddress(); + const [historyData, setHistoryData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); // Error state for API failures + const [sortKey, setSortKey] = useState(null); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + + /** + * Fetches history data from the REST API and updates state. + * Handles error and loading states. + */ + const fetchHistory = () => { + if (!walletAddress) return; + setLoading(true); + setError(false); // Reset error before fetching + fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((res) => res.json()) + .then((data) => { + // Map API response to HistoryEntry structure + const entries: HistoryEntry[] = (data.history || []).map((item: any, idx: number) => { + const isDeposit = item.transactionType === 'Deposit'; + return { + id: String(idx + 1), // Numeric id starting from 1 + date: formatTimestamp(item.timestamp), + type: isDeposit ? 'Top-up' : 'Spend', + amount: formatAmount(item.amount, isDeposit), + token: { + symbol: 'USDC', + value: formatTokenValue(item.amount), + icon: isDeposit ? '🔵' : '🔴', // Blue for deposit, red otherwise + }, + }; + }); + setHistoryData(entries); + }) + .catch(() => { + setHistoryData([]); + setError(true); // Set error on failure + }) + .finally(() => setLoading(false)); + }; + + // Fetch history on wallet address change + useEffect(() => { + fetchHistory(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletAddress]); + + /** + * Returns the sort icon for a given column key. + * ⇅ for unsorted, ▲ for ascending, ▼ for descending. + */ + const getSortIcon = (key: SortKey) => { + if (sortKey !== key) return '⇅'; + return sortOrder === 'asc' ? '▲' : '▼'; + }; + + /** + * Handles sorting logic when a column header is clicked. + * Toggles sort order if the same column is clicked. + */ + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortOrder('asc'); + } + }; + + /** + * Returns sorted history data based on selected column and order. + */ + const sortedHistory = [...historyData].sort((a, b) => { + if (!sortKey) return 0; + let valA, valB; + switch (sortKey) { + case 'id': + valA = Number(a.id); + valB = Number(b.id); + break; + case 'date': + valA = new Date(a.date).getTime(); + valB = new Date(b.date).getTime(); + break; + case 'type': + valA = a.type; + valB = b.type; + break; + case 'amount': + valA = Number(a.amount.replace(/[^0-9.-]+/g, '')); + valB = Number(b.amount.replace(/[^0-9.-]+/g, '')); + break; + case 'token': + valA = a.token.value; + valB = b.token.value; + break; + default: + return 0; + } + if (valA < valB) return sortOrder === 'asc' ? -1 : 1; + if (valA > valB) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); return ( + {/* Table header with refresh button */}
📋 Gas Tank History + + 🔄 +
- - - Date ▲ - Type ▼ - Amount ▼ - Token ▼ - - - - {sortedHistory.map((entry) => ( - - {entry.date} - {entry.type} - - {entry.amount} - - - {entry.token.icon} - - {entry.token.value} - {entry.token.symbol} - - - - ))} - -
+ {/* Table content: loading, error, empty, or data */} + {loading ? ( + Loading... + ) : ( + + + + handleSort('id')}> + # + {getSortIcon('id')} + + handleSort('date')}> + Date + {getSortIcon('date')} + + handleSort('type')}> + Type + {getSortIcon('type')} + + handleSort('amount')}> + Amount + {getSortIcon('amount')} + + handleSort('token')}> + Token + {getSortIcon('token')} + + + + + {error ? ( + // Error message if API call fails + + Error has occurred while fetching. Please try after some time + + ) : sortedHistory.length === 0 ? ( + // Empty message if no data + No items to display + ) : ( + // Render table rows for each entry + sortedHistory.map((entry) => ( + + {entry.id} + {entry.date} + {entry.type} + + {entry.amount} + + + {entry.token.icon} + + {entry.token.value} + {entry.token.symbol} + + + + )) + )} + +
+
+ )}
); }; +/** + * Converts a UNIX timestamp (seconds) to a formatted date string. + */ +function formatTimestamp(timestamp: string): string { + const date = new Date(Number(timestamp) * 1000); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Formats the amount as a USD string, with + for deposit and - for spend. + */ +function formatAmount(amount: string, isDeposit: boolean): string { + const value = Number(formatUnits(BigInt(amount), 6)).toFixed(2); + return `${isDeposit ? '+' : '-'}$${value}`; +} + +/** + * Formats the token value using USDC decimals (6). + */ +function formatTokenValue(amount: string): string { + return formatUnits(BigInt(amount), 6); +} + +// Styled-components for layout and table styling + +const Loading = styled.div` + color: #9ca3af; + font-size: 14px; + text-align: center; + padding: 24px 0; +`; + const Container = styled.div` background: #1a1a1a; border-radius: 16px; @@ -141,17 +284,23 @@ const Title = styled.h2` margin: 0; `; +const TableWrapper = styled.div` + max-height: 340px; + overflow-y: auto; +`; + const Table = styled.div` width: 100%; `; const TableHeader = styled.div` display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid #333; margin-bottom: 8px; + cursor: pointer; `; const HeaderCell = styled.div` @@ -160,6 +309,15 @@ const HeaderCell = styled.div` font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 4px; + user-select: none; +`; + +const SortIcon = styled.span` + font-size: 12px; + margin-left: 2px; `; const TableBody = styled.div` @@ -170,7 +328,7 @@ const TableBody = styled.div` const TableRow = styled.div` display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; gap: 16px; padding: 12px 0; border-bottom: 1px solid #2a2a2a; @@ -181,6 +339,12 @@ const TableRow = styled.div` } `; +const IdCell = styled.div` + color: #ffffff; + font-size: 14px; + text-align: center; +`; + const DateCell = styled.div` color: #ffffff; font-size: 14px; @@ -230,4 +394,41 @@ const TokenSymbol = styled.span` font-size: 12px; `; +/** + * Refresh button for manually refetching history data. + */ +const RefreshButton = styled.button` + margin-left: auto; + background: none; + border: none; + color: #9ca3af; + font-size: 18px; + cursor: pointer; + padding: 0 4px; + transition: color 0.2s; + &:hover { + color: #fff; + } +`; + +/** + * Message shown when there are no items to display. + */ +const NoItemsMsg = styled.div` + color: #9ca3af; + font-size: 16px; + text-align: center; + padding: 48px 0; +`; + +/** + * Message shown when an error occurs while fetching data. + */ +const ErrorMsg = styled.div` + color: #ef4444; + font-size: 16px; + text-align: center; + padding: 48px 0; +`; + export default GasTankHistory; From 271b010602a9ffa3816fcbbeb16278eabd2b4bfb Mon Sep 17 00:00:00 2001 From: vignesha22 Date: Thu, 4 Sep 2025 13:48:01 +0530 Subject: [PATCH 10/20] calculated total spend --- .../gas-tank/components/GasTankHistory.tsx | 56 +++++++++++++++++++ .../gas-tank/components/UniversalGasTank.tsx | 19 ++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index 60cfcf84..8c390c6c 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -250,6 +250,62 @@ function formatTokenValue(amount: string): string { return formatUnits(BigInt(amount), 6); } +/** + * Custom hook to fetch and expose gas tank history and total spend. + */ +export function useGasTankHistory(walletAddress: string | undefined) { + const [historyData, setHistoryData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + // Calculate total spend (sum of all Spend amounts) + const totalSpend = historyData + .filter((entry) => entry.type === 'Spend') + .reduce((acc, entry) => acc + Number(entry.amount.replace(/[^0-9.-]+/g, '')), 0); + + const fetchHistory = () => { + if (!walletAddress) return; + setLoading(true); + setError(false); + fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((res) => res.json()) + .then((data) => { + const entries: HistoryEntry[] = (data.history || []).map((item: any, idx: number) => { + const isDeposit = item.transactionType === 'Deposit'; + return { + id: String(idx + 1), + date: formatTimestamp(item.timestamp), + type: isDeposit ? 'Top-up' : 'Spend', + amount: formatAmount(item.amount, isDeposit), + token: { + symbol: 'USDC', + value: formatTokenValue(item.amount), + icon: isDeposit ? '🔵' : '🔴', + }, + }; + }); + setHistoryData(entries); + }) + .catch(() => { + setHistoryData([]); + setError(true); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchHistory(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletAddress]); + + return { historyData, loading, error, totalSpend, refetch: fetchHistory }; +} + // Styled-components for layout and table styling const Loading = styled.div` diff --git a/src/apps/gas-tank/components/UniversalGasTank.tsx b/src/apps/gas-tank/components/UniversalGasTank.tsx index b29703a9..79e69461 100644 --- a/src/apps/gas-tank/components/UniversalGasTank.tsx +++ b/src/apps/gas-tank/components/UniversalGasTank.tsx @@ -2,21 +2,32 @@ import { useState } from 'react'; import styled from 'styled-components'; import { CircularProgress } from '@mui/material'; +import { useWalletAddress } from '@etherspot/transaction-kit'; // components import TopUpModal from './TopUpModal'; // hooks import useGasTankBalance from '../hooks/useGasTankBalance'; +import { useGasTankHistory } from './GasTankHistory'; // import the hook const UniversalGasTank = () => { + const walletAddress = useWalletAddress(); const { totalBalance, isLoading: isBalanceLoading, error: balanceError, refetch, } = useGasTankBalance(); - const [totalSpend] = useState(82.97); + + // Use the custom hook to get totalSpend from history + const { + totalSpend = 0, + loading: isHistoryLoading, + error: historyError, + refetch: refetchHistory, + } = useGasTankHistory(walletAddress); + const [showTopUpModal, setShowTopUpModal] = useState(false); const handleTopUp = () => { @@ -68,7 +79,11 @@ const UniversalGasTank = () => { Total Spend: - ${totalSpend} + + {isHistoryLoading || historyError + ? '$0.00' + : `$${totalSpend.toFixed(2)}`} + From b5bb0b6a8a29a5a3fda9c2a571b376235be2aeb3 Mon Sep 17 00:00:00 2001 From: vignesha22 Date: Mon, 8 Sep 2025 11:13:02 +0530 Subject: [PATCH 11/20] added missing items --- .../gas-tank/components/GasTankHistory.tsx | 10 +- src/apps/gas-tank/components/TopUpModal.tsx | 374 ++++++++++++++++-- 2 files changed, 348 insertions(+), 36 deletions(-) diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index 8c390c6c..ce0681d6 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -258,10 +258,7 @@ export function useGasTankHistory(walletAddress: string | undefined) { const [loading, setLoading] = useState(true); const [error, setError] = useState(false); - // Calculate total spend (sum of all Spend amounts) - const totalSpend = historyData - .filter((entry) => entry.type === 'Spend') - .reduce((acc, entry) => acc + Number(entry.amount.replace(/[^0-9.-]+/g, '')), 0); + const [totalSpend, setTotalSpend] = useState(0); const fetchHistory = () => { if (!walletAddress) return; @@ -290,6 +287,11 @@ export function useGasTankHistory(walletAddress: string | undefined) { }; }); setHistoryData(entries); + // Calculate total spend (sum of all Spend amounts) + const totalSpendCal = entries + .filter((entry) => entry.type === 'Spend') + .reduce((acc, entry) => acc + Number(entry.amount.replace(/[^0-9.-]+/g, '')), 0); + setTotalSpend(Math.abs(totalSpendCal)); }) .catch(() => { setHistoryData([]); diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx index 55f6f209..0827bbba 100644 --- a/src/apps/gas-tank/components/TopUpModal.tsx +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -57,6 +57,13 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const [amount, setAmount] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [portfolioTokens, setPortfolioTokens] = useState([]); + const [showSwapConfirmation, setShowSwapConfirmation] = useState(false); + const [swapDetails, setSwapDetails] = useState<{ + receiveAmount: string; + bestOffer: any; + swapTransactions: any[]; + } | null>(null); + const [swapAmountUsdPrice, setSwapAmountUsdPrice] = useState(0); const { transactionDebugLog } = useTransactionDebugLogger(); const walletPortfolio = useAppSelector( @@ -154,39 +161,17 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { Number(amount), ); - // Add swap transactions to batch - swapTransactions.forEach((tx, index) => { - const value = tx.value || '0'; - // Handle bigint conversion properly - let bigIntValue: bigint; - if (typeof value === 'bigint') { - // If value is already a native bigint, use it directly - bigIntValue = value; - } else if (value) { - // If value exists but is not a bigint, convert it - bigIntValue = BigNumber.from(value).toBigInt(); - } else { - // If value is undefined/null, use 0 - bigIntValue = BigInt(0); - } - - const integerValue = formatEther(bigIntValue); - if (!tx.to || !isAddress(tx.to)) { - setErrorMsg('Invalid transaction target for swap route. Please try again.'); - logExchangeEvent('Invalid tx.to in swap step', 'error', { tx }, { component: 'TopUpModal', action: 'invalid_tx_to' }); - setIsProcessing(false); - return; - } - addToBatch({ - title: `Swap to USDC ${index + 1}/${swapTransactions.length}`, - description: `Convert ${amount} ${selectedToken.symbol} to USDC for Gas Tank`, - to: tx.to, - value: integerValue, - data: tx.data, - chainId: chainNameToChainIdTokensData(selectedToken.blockchain), - }); - }); receiveSwapAmount = bestOffer.tokenAmountToReceive.toString(); + + // Show swap confirmation UI and pause execution + setSwapDetails({ + receiveAmount: receiveSwapAmount, + bestOffer, + swapTransactions + }); + setShowSwapConfirmation(true); + setIsProcessing(false); + return; } catch (swapError) { console.error('Error getting swap transactions:', swapError); console.warn( @@ -296,6 +281,11 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { if (value === '' || /^\d*\.?\d*$/.test(value)) { setAmount(value); } + let tokenUsdPrice = 0; + if (selectedToken) { + tokenUsdPrice = Number(value) * (selectedToken.price ?? 0); + } + setSwapAmountUsdPrice(tokenUsdPrice); }; const getMaxAmount = () => { @@ -308,8 +298,203 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { setAmount(getMaxAmount() || ''); }; + const handleConfirmSwap = async () => { + if (!swapDetails || !selectedToken || !walletAddress) return; + + setIsProcessing(true); + setShowSwapConfirmation(false); + + try { + // Add swap transactions to batch + swapDetails.swapTransactions.forEach((tx, index) => { + const value = tx.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + if (!tx.to || !isAddress(tx.to)) { + setErrorMsg('Invalid transaction target for swap route. Please try again.'); + logExchangeEvent('Invalid tx.to in swap step', 'error', { tx }, { component: 'TopUpModal', action: 'invalid_tx_to' }); + setIsProcessing(false); + return; + } + addToBatch({ + title: `Swap to USDC ${index + 1}/${swapDetails.swapTransactions.length}`, + description: `Convert ${amount} ${selectedToken.symbol} to USDC for Gas Tank`, + to: tx.to, + value: integerValue, + data: tx.data, + chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + }); + }); + + // Continue with the paymaster API call for USDC deposits + const response = await fetch( + `${paymasterUrl}/getTransactionForDeposit?chainId=${chainNameToChainIdTokensData(selectedToken.blockchain)}&amount=${swapDetails.receiveAmount}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Error fetching transaction data:', errorText); + logExchangeError('Failed to fetch transaction data', { "error": errorText }, { component: 'TopUpModal', action: 'failed_to_fetch_transaction_data' }); + setErrorMsg('Failed to fetch transaction data. Please try again with different token or amount.'); + setIsProcessing(false); + return; + } + + const transactionData = await response.json(); + transactionDebugLog('Gas Tank Top-up transaction data', transactionData); + + // Add transactions to batch + if (Array.isArray(transactionData.result)) { + transactionData.result.forEach((tx: {value?: string, to: string, data?: string}, index: number) => { + const value = tx.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + addToBatch({ + title: `Gas Tank Top-up ${index + 1}/${transactionData.result.length}`, + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: tx.to, + value: integerValue, + data: tx.data, + chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + }); + }); + } else { + const value = transactionData.result.value || '0'; + // Handle bigint conversion properly + let bigIntValue: bigint; + if (typeof value === 'bigint') { + // If value is already a native bigint, use it directly + bigIntValue = value; + } else if (value) { + // If value exists but is not a bigint, convert it + bigIntValue = BigNumber.from(value).toBigInt(); + } else { + // If value is undefined/null, use 0 + bigIntValue = BigInt(0); + } + + const integerValue = formatEther(bigIntValue); + // Single transaction + addToBatch({ + title: 'Gas Tank Top-up', + description: `Add ${amount} ${selectedToken.symbol} to Gas Tank`, + to: transactionData.result.to, + value: integerValue, + data: transactionData.result.data, + chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + }); + } + + // Show the send modal with the batched transactions + setShowBatchSendModal(true); + showSend(); + onSuccess?.(); + } catch (error) { + console.error('Error processing top-up:', error); + console.warn('Failed to process top-up. Please try again.'); + logExchangeError('Failed to process top-up', { "error": error }, { component: 'TopUpModal', action: 'failed_to_process_top_up' }); + setErrorMsg('Failed to process top-up. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + + const handleCancelSwap = () => { + setShowSwapConfirmation(false); + setSwapDetails(null); + }; + if (!isOpen) return null; + // Swap Confirmation Modal + if (showSwapConfirmation && swapDetails && selectedToken) { + return ( + + +
+ Confirm Swap + +
+ + + + + Swap Summary + + From: + {amount} {selectedToken.symbol} + + + To: + {formatTokenAmount(Number(swapDetails.receiveAmount))} USDC + + + On: + {selectedToken.blockchain} + + + + + + This swap will be executed first, then the USDC will be added to your Gas Tank. + + + + + + Cancel + + + {isProcessing ? ( + <> + + Processing... + + ) : ( + 'Confirm Swap' + )} + + + + +
+
+ ); + } + return ( @@ -379,6 +564,11 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { MAX
+ {selectedToken && amount && !isNaN(Number(amount)) && Number(amount) > 0 && ( + + ${(Number(amount) * (selectedToken.price || 0)).toFixed(2)} + + )} Available: {getMaxAmount()} {selectedToken.symbol} @@ -596,6 +786,18 @@ const AmountInput = styled.input` } `; +const UsdPriceDisplay = styled.div` + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + padding: 12px 16px; + color: #9ca3af; + font-size: 16px; + white-space: nowrap; + min-width: 80px; + text-align: right; +`; + const MaxButton = styled.button` background: #7c3aed; color: white; @@ -646,4 +848,112 @@ const TopUpButton = styled.button` } `; +const SwapConfirmationContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; +`; + +const SwapDetailsSection = styled.div` + background: #2a2a2a; + border-radius: 12px; + padding: 20px; + border: 1px solid #444; +`; + +const SwapTitle = styled.h3` + color: #ffffff; + font-size: 18px; + font-weight: 600; + margin: 0 0 16px 0; +`; + +const SwapDetail = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } +`; + +const SwapLabel = styled.div` + color: #9ca3af; + font-size: 14px; +`; + +const SwapValue = styled.div` + color: #ffffff; + font-size: 14px; + font-weight: 600; +`; + +const WarningBox = styled.div` + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 8px; + padding: 16px; +`; + +const WarningText = styled.p` + color: #fbbf24; + font-size: 14px; + margin: 0; + line-height: 1.5; +`; + +const ButtonContainer = styled.div` + display: flex; + gap: 12px; +`; + +const CancelButton = styled.button` + flex: 1; + background: transparent; + color: #9ca3af; + border: 1px solid #444; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: #2a2a2a; + color: #ffffff; + border-color: #666; + } +`; + +const ConfirmButton = styled.button` + flex: 1; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: white; + border: none; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + export default TopUpModal; From 2f5d00aae935864f11fad53cb67f62122568aba2 Mon Sep 17 00:00:00 2001 From: vignesha22 Date: Mon, 8 Sep 2025 16:51:02 +0530 Subject: [PATCH 12/20] added auto-refresh and usd value --- .../gas-tank/components/GasTankHistory.tsx | 60 ++++++++++++++-- src/apps/gas-tank/components/TopUpModal.tsx | 71 +++++++++++++++++-- src/apps/gas-tank/hooks/useGasTankBalance.tsx | 12 ++++ 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index ce0681d6..217ad8d0 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import styled from 'styled-components'; import { useWalletAddress } from '@etherspot/transaction-kit'; import { formatUnits } from 'viem'; @@ -46,7 +46,7 @@ const GasTankHistory = () => { * Fetches history data from the REST API and updates state. * Handles error and loading states. */ - const fetchHistory = () => { + const fetchHistory = useCallback(() => { if (!walletAddress) return; setLoading(true); setError(false); // Reset error before fetching @@ -80,13 +80,24 @@ const GasTankHistory = () => { setError(true); // Set error on failure }) .finally(() => setLoading(false)); - }; + }, [walletAddress]); // Fetch history on wallet address change useEffect(() => { fetchHistory(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletAddress]); + }, [fetchHistory]); + + // Auto-refresh every 30 seconds when component is active + useEffect(() => { + if (!walletAddress) return; + + const interval = setInterval(() => { + fetchHistory(); + }, 30000); // 30 seconds + + // Cleanup interval on component unmount + return () => clearInterval(interval); + }, [walletAddress, fetchHistory]); /** * Returns the sort icon for a given column key. @@ -345,6 +356,45 @@ const Title = styled.h2` const TableWrapper = styled.div` max-height: 340px; overflow-y: auto; + + /* Custom scrollbar styles */ + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.3s ease; + + &:hover { + scrollbar-color: #7c3aed #2a2a2a; + } + + /* WebKit scrollbar styles */ + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 4px; + transition: background 0.3s ease, opacity 0.3s ease; + } + + &:hover::-webkit-scrollbar-thumb { + background: #7c3aed; + } + + &::-webkit-scrollbar-thumb:hover { + background: #8b5cf6; + } + + /* Auto-hide behavior */ + &:not(:hover)::-webkit-scrollbar-thumb { + opacity: 0; + transition: opacity 0.5s ease 1s; + } `; const Table = styled.div` diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx index 0827bbba..5ce01c5d 100644 --- a/src/apps/gas-tank/components/TopUpModal.tsx +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -540,9 +540,17 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { {token.blockchain} - - {formatTokenAmount(token.balance)} - + + + {formatTokenAmount(token.balance)} + + + {(() => { + const usdValue = (token.balance || 0) * (token.price || 0); + return usdValue < 0.01 ? '<$0.01' : `$${usdValue.toFixed(2)}`; + })()} + + ))} @@ -566,7 +574,10 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { {selectedToken && amount && !isNaN(Number(amount)) && Number(amount) > 0 && ( - ${(Number(amount) * (selectedToken.price || 0)).toFixed(2)} + {(() => { + const usdValue = Number(amount) * (selectedToken.price || 0); + return usdValue < 0.01 ? '<$0.01' : `$${usdValue.toFixed(2)}`; + })()} )} @@ -700,6 +711,45 @@ const TokenList = styled.div` overflow-y: auto; border: 1px solid #333; border-radius: 12px; + + /* Custom scrollbar styles */ + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.3s ease; + + &:hover { + scrollbar-color: #7c3aed #2a2a2a; + } + + /* WebKit scrollbar styles */ + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 4px; + transition: background 0.3s ease, opacity 0.3s ease; + } + + &:hover::-webkit-scrollbar-thumb { + background: #7c3aed; + } + + &::-webkit-scrollbar-thumb:hover { + background: #8b5cf6; + } + + /* Auto-hide behavior */ + &:not(:hover)::-webkit-scrollbar-thumb { + opacity: 0; + transition: opacity 0.5s ease 1s; + } `; const TokenItem = styled.div<{ $isSelected: boolean }>` @@ -761,6 +811,19 @@ const TokenBalance = styled.div` font-weight: 600; `; +const TokenBalanceContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +`; + +const TokenBalanceUSD = styled.div` + color: #9ca3af; + font-size: 12px; + font-weight: 400; +`; + const AmountContainer = styled.div` display: flex; align-items: center; diff --git a/src/apps/gas-tank/hooks/useGasTankBalance.tsx b/src/apps/gas-tank/hooks/useGasTankBalance.tsx index a483c0cc..50d57e42 100644 --- a/src/apps/gas-tank/hooks/useGasTankBalance.tsx +++ b/src/apps/gas-tank/hooks/useGasTankBalance.tsx @@ -94,6 +94,18 @@ const useGasTankBalance = (): UseGasTankBalanceReturn => { fetchGasTankBalance(); }, [fetchGasTankBalance]); + // Auto-refresh every 30 seconds when component is active + useEffect(() => { + if (!walletAddress) return; + + const interval = setInterval(() => { + fetchGasTankBalance(); + }, 30000); // 30 seconds + + // Cleanup interval on component unmount + return () => clearInterval(interval); + }, [walletAddress, fetchGasTankBalance]); + return { totalBalance, chainBalances, From 4802b5749464b23d62e1dcbcfef87a98da4c310b Mon Sep 17 00:00:00 2001 From: vignesha22 Date: Fri, 12 Sep 2025 19:56:47 +0530 Subject: [PATCH 13/20] added history ui changes --- .../gas-tank/components/GasTankHistory.tsx | 113 +++++++++++++++--- 1 file changed, 94 insertions(+), 19 deletions(-) diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index 217ad8d0..6ec24c89 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -4,6 +4,17 @@ import styled from 'styled-components'; import { useWalletAddress } from '@etherspot/transaction-kit'; import { formatUnits } from 'viem'; +// Import chain logos +import logoArbitrum from '../../../assets/images/logo-arbitrum.png'; +import logoAvalanche from '../../../assets/images/logo-avalanche.png'; +import logoBase from '../../../assets/images/logo-base.png'; +import logoBsc from '../../../assets/images/logo-bsc.png'; +import logoEthereum from '../../../assets/images/logo-ethereum.png'; +import logoGnosis from '../../../assets/images/logo-gnosis.png'; +import logoOptimism from '../../../assets/images/logo-optimism.png'; +import logoPolygon from '../../../assets/images/logo-polygon.png'; +import logoUnknown from '../../../assets/images/logo-unknown.png'; + /** * Represents a single entry in the gas tank history table. */ @@ -16,6 +27,7 @@ interface HistoryEntry { symbol: string; value: string; icon: string; + chainId: string; }; } @@ -29,6 +41,33 @@ type SortKey = 'id' | 'date' | 'type' | 'amount' | 'token'; */ const API_URL = import.meta.env.VITE_PAYMASTER_URL || ''; +/** + * Maps chainId to the corresponding chain logo image + */ +const getChainLogo = (chainId: string): string => { + const chainIdNum = parseInt(chainId); + switch (chainIdNum) { + case 1: // Ethereum + return logoEthereum; + case 137: // Polygon + return logoPolygon; + case 42161: // Arbitrum + return logoArbitrum; + case 10: // Optimism + return logoOptimism; + case 8453: // Base + return logoBase; + case 56: // BSC + return logoBsc; + case 43114: // Avalanche + return logoAvalanche; + case 100: // Gnosis + return logoGnosis; + default: + return logoUnknown; + } +}; + /** * GasTankHistory component * Displays a sortable, scrollable table of gas tank transaction history for the connected wallet. @@ -50,7 +89,7 @@ const GasTankHistory = () => { if (!walletAddress) return; setLoading(true); setError(false); // Reset error before fetching - fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { + fetch(`${API_URL}/getGasTankHistory?sender=0x70e8741c1758Ba32176B188286B8086956627B1c`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -65,18 +104,24 @@ const GasTankHistory = () => { id: String(idx + 1), // Numeric id starting from 1 date: formatTimestamp(item.timestamp), type: isDeposit ? 'Top-up' : 'Spend', - amount: formatAmount(item.amount, isDeposit), + amount: formatAmount(isDeposit ? item.amountUsd : item.amount, isDeposit), token: { - symbol: 'USDC', - value: formatTokenValue(item.amount), - icon: isDeposit ? '🔵' : '🔴', // Blue for deposit, red otherwise + symbol: isDeposit ? item.swap[0].asset.symbol : 'USDC', + value: isDeposit ? item.amount : formatTokenValue(item.amount), + icon: isDeposit + ? (item.swap && item.swap.length > 0 + ? item.swap[0].asset.logo + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694') + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694', + chainId: item.chainId || '1', }, }; }); setHistoryData(entries); }) - .catch(() => { + .catch((err) => { setHistoryData([]); + console.error('Error fetching gas tank history:', err); setError(true); // Set error on failure }) .finally(() => setLoading(false)); @@ -216,7 +261,10 @@ const GasTankHistory = () => { {entry.amount} - {entry.token.icon} + + + + {entry.token.value} {entry.token.symbol} @@ -250,7 +298,9 @@ function formatTimestamp(timestamp: string): string { * Formats the amount as a USD string, with + for deposit and - for spend. */ function formatAmount(amount: string, isDeposit: boolean): string { - const value = Number(formatUnits(BigInt(amount), 6)).toFixed(2); + let value = amount; + if (!isDeposit) value = Number(formatUnits(BigInt(amount), 6)).toFixed(2); + if (Number(value) <= 0) value = '<0.01'; return `${isDeposit ? '+' : '-'}$${value}`; } @@ -275,7 +325,7 @@ export function useGasTankHistory(walletAddress: string | undefined) { if (!walletAddress) return; setLoading(true); setError(false); - fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { + fetch(`${API_URL}/getGasTankHistory?sender=0x70e8741c1758Ba32176B188286B8086956627B1c`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -286,14 +336,19 @@ export function useGasTankHistory(walletAddress: string | undefined) { const entries: HistoryEntry[] = (data.history || []).map((item: any, idx: number) => { const isDeposit = item.transactionType === 'Deposit'; return { - id: String(idx + 1), + id: String(idx + 1), // Numeric id starting from 1 date: formatTimestamp(item.timestamp), type: isDeposit ? 'Top-up' : 'Spend', - amount: formatAmount(item.amount, isDeposit), + amount: formatAmount(isDeposit ? item.amountUsd : item.amount, isDeposit), token: { - symbol: 'USDC', - value: formatTokenValue(item.amount), - icon: isDeposit ? '🔵' : '🔴', + symbol: item.swap ? item.swap.asset.symbol : 'USDC', + value: item.swap ? item.amount : formatTokenValue(item.amount), + icon: isDeposit + ? (item.swap && item.swap.length > 0 + ? item.swap[0].asset.logo + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694') + : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694', + chainId: item.chainId || '1', }, }; }); @@ -475,14 +530,34 @@ const TokenCell = styled.div` gap: 8px; `; -const TokenIcon = styled.span` - width: 20px; - height: 20px; - border-radius: 50%; +const TokenIconContainer = styled.div` + position: relative; + width: 24px; + height: 24px; display: flex; align-items: center; justify-content: center; - font-size: 12px; +`; + +const TokenIcon = styled.img` + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + background: #2a2a2a; +`; + +const ChainOverlay = styled.img` + position: absolute; + bottom: -2px; + right: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + object-fit: cover; + background: #fff; + border: 1px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); `; const TokenInfo = styled.div` From 28a3d31de57ae6c136ef999c03a8ccb35c1be95f Mon Sep 17 00:00:00 2001 From: vignesha22 Date: Fri, 12 Sep 2025 20:08:12 +0530 Subject: [PATCH 14/20] changed to dynamic fetching of gas tank history --- src/apps/gas-tank/components/GasTankHistory.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index 6ec24c89..6d05bd31 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -89,7 +89,7 @@ const GasTankHistory = () => { if (!walletAddress) return; setLoading(true); setError(false); // Reset error before fetching - fetch(`${API_URL}/getGasTankHistory?sender=0x70e8741c1758Ba32176B188286B8086956627B1c`, { + fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -325,7 +325,7 @@ export function useGasTankHistory(walletAddress: string | undefined) { if (!walletAddress) return; setLoading(true); setError(false); - fetch(`${API_URL}/getGasTankHistory?sender=0x70e8741c1758Ba32176B188286B8086956627B1c`, { + fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { method: 'GET', headers: { 'Content-Type': 'application/json', From 18cc95a43ffd6ef4ee2ede09d47c6b0d7ca28ea2 Mon Sep 17 00:00:00 2001 From: vignesha22 Date: Mon, 15 Sep 2025 14:48:29 +0530 Subject: [PATCH 15/20] fromAmount bug fix --- src/apps/gas-tank/hooks/useOffer.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/apps/gas-tank/hooks/useOffer.tsx b/src/apps/gas-tank/hooks/useOffer.tsx index 7c88d8dd..d0c16c60 100644 --- a/src/apps/gas-tank/hooks/useOffer.tsx +++ b/src/apps/gas-tank/hooks/useOffer.tsx @@ -141,19 +141,11 @@ const useOffer = () => { fromTokenAddress, fromChainId ); - - /** - * Step 2: Apply fee deduction using BigInt arithmetic - * Convert to wei first, then apply 1% fee deduction using integer math - * This prevents precision loss for large amounts or tokens with many decimals - */ - const fromAmountInWei = parseUnits(String(fromAmount), fromTokenDecimals); - const feeDeduction = fromAmountInWei / BigInt(100); // 1% fee - const fromAmountFeeDeducted = fromAmountInWei - feeDeduction; const toTokenAddress = USDC_ADDRESSES[fromChainId]; const toTokenDecimals = 6; // USDC has 6 decimals + /** - * Step 3: Create route request for LiFi + * Step 2: Create route request for LiFi * This request includes all necessary parameters for finding swap routes */ const routesRequest: RoutesRequest = { @@ -161,7 +153,7 @@ const useOffer = () => { toChainId: fromChainId, // Swapping within the same chain fromTokenAddress: fromTokenAddressWithWrappedCheck, toTokenAddress, - fromAmount: fromAmountFeeDeducted.toString(), + fromAmount: parseUnits(String(fromAmount), fromTokenDecimals).toString(), options: { slippage, bridges: { From ea18ab8e40d6c3160e63916f4c495aef7d4e019e Mon Sep 17 00:00:00 2001 From: Vignesh Date: Tue, 7 Oct 2025 03:34:52 +0530 Subject: [PATCH 16/20] Gas Tank Ui Fixes --- package-lock.json | 761 ++---------------- package.json | 2 + src/apps/gas-tank/assets/arrow-down.svg | 3 + src/apps/gas-tank/assets/close-icon.svg | 12 + src/apps/gas-tank/assets/confirmed-icon.svg | 3 + src/apps/gas-tank/assets/copy-icon.svg | 6 + src/apps/gas-tank/assets/esc-icon.svg | 14 + src/apps/gas-tank/assets/failed-icon.svg | 4 + src/apps/gas-tank/assets/gas-tank-icon.png | Bin 0 -> 7527 bytes src/apps/gas-tank/assets/globe-icon.svg | 10 + src/apps/gas-tank/assets/moreinfo-icon.svg | 7 + src/apps/gas-tank/assets/new-tab.svg | 3 + src/apps/gas-tank/assets/pending.svg | 4 + src/apps/gas-tank/assets/refresh-icon.svg | 6 + src/apps/gas-tank/assets/seach-icon.svg | 3 + src/apps/gas-tank/assets/selected-icon.svg | 4 + src/apps/gas-tank/assets/setting-icon.svg | 3 + .../transaction-failed-details-icon.svg | 5 + .../gas-tank/assets/usd-coin-usdc-logo.png | Bin 0 -> 23558 bytes src/apps/gas-tank/assets/wallet.svg | 16 + src/apps/gas-tank/assets/warning.svg | 5 + src/apps/gas-tank/components/GasTank.tsx | 26 +- .../components/GasTankHistory.styles.ts | 289 +++++++ .../gas-tank/components/GasTankHistory.tsx | 520 ++++-------- src/apps/gas-tank/components/Misc/Close.tsx | 15 + .../gas-tank/components/Misc/CloseButton.tsx | 46 ++ src/apps/gas-tank/components/Misc/Esc.tsx | 36 + src/apps/gas-tank/components/Misc/Refresh.tsx | 34 + .../gas-tank/components/Misc/Settings.tsx | 13 + src/apps/gas-tank/components/Misc/Tooltip.tsx | 117 +++ .../gas-tank/components/Price/TokenPrice.tsx | 33 + .../components/Price/TokenPriceChange.tsx | 56 ++ .../components/Search/ChainOverlay.tsx | 111 +++ .../components/Search/ChainSelect.tsx | 13 + .../components/Search/PortfolioTokenList.tsx | 281 +++++++ .../gas-tank/components/Search/Search.tsx | 324 ++++++++ src/apps/gas-tank/components/Search/Sort.tsx | 29 + .../gas-tank/components/Search/TokenList.tsx | 185 +++++ .../Search/tests/PortfolioTokenList.test.tsx | 346 ++++++++ .../components/Search/tests/Search.test.tsx | 377 +++++++++ .../PortfolioTokenList.test.tsx.snap | 230 ++++++ .../tests/__snapshots__/Search.test.tsx.snap | 158 ++++ src/apps/gas-tank/components/TopUpModal.tsx | 572 +++++++++---- .../components/UniversalGasTank.styles.ts | 365 +++++++++ .../gas-tank/components/UniversalGasTank.tsx | 359 +++------ src/apps/gas-tank/components/shared.styles.ts | 61 ++ src/apps/gas-tank/constants/tokens.ts | 15 + src/apps/gas-tank/hooks/useGasTankBalance.tsx | 25 +- src/apps/gas-tank/hooks/useTokenSearch.ts | 34 + src/apps/gas-tank/icon.png | Bin 20211 -> 7527 bytes src/apps/gas-tank/types/tokens.ts | 30 + src/apps/gas-tank/types/types.ts | 200 +++++ src/apps/gas-tank/utils/constants.ts | 70 ++ src/apps/gas-tank/utils/number.ts | 53 ++ src/apps/gas-tank/utils/parseSearchData.ts | 99 +++ src/apps/gas-tank/utils/time.ts | 25 + .../GasTankPaymasterTile.tsx | 410 ++++++++++ src/apps/pillarx-app/index.tsx | 2 + .../SendModal/SendModalTokensTabView.tsx | 215 +++-- src/types/api.ts | 1 + src/types/blockchain.ts | 9 + src/utils/blockchain.ts | 162 +++- src/utils/number.tsx | 40 +- 63 files changed, 5259 insertions(+), 1598 deletions(-) create mode 100644 src/apps/gas-tank/assets/arrow-down.svg create mode 100644 src/apps/gas-tank/assets/close-icon.svg create mode 100644 src/apps/gas-tank/assets/confirmed-icon.svg create mode 100644 src/apps/gas-tank/assets/copy-icon.svg create mode 100644 src/apps/gas-tank/assets/esc-icon.svg create mode 100644 src/apps/gas-tank/assets/failed-icon.svg create mode 100644 src/apps/gas-tank/assets/gas-tank-icon.png create mode 100644 src/apps/gas-tank/assets/globe-icon.svg create mode 100644 src/apps/gas-tank/assets/moreinfo-icon.svg create mode 100644 src/apps/gas-tank/assets/new-tab.svg create mode 100644 src/apps/gas-tank/assets/pending.svg create mode 100644 src/apps/gas-tank/assets/refresh-icon.svg create mode 100644 src/apps/gas-tank/assets/seach-icon.svg create mode 100644 src/apps/gas-tank/assets/selected-icon.svg create mode 100644 src/apps/gas-tank/assets/setting-icon.svg create mode 100644 src/apps/gas-tank/assets/transaction-failed-details-icon.svg create mode 100644 src/apps/gas-tank/assets/usd-coin-usdc-logo.png create mode 100644 src/apps/gas-tank/assets/wallet.svg create mode 100644 src/apps/gas-tank/assets/warning.svg create mode 100644 src/apps/gas-tank/components/GasTankHistory.styles.ts create mode 100644 src/apps/gas-tank/components/Misc/Close.tsx create mode 100644 src/apps/gas-tank/components/Misc/CloseButton.tsx create mode 100644 src/apps/gas-tank/components/Misc/Esc.tsx create mode 100644 src/apps/gas-tank/components/Misc/Refresh.tsx create mode 100644 src/apps/gas-tank/components/Misc/Settings.tsx create mode 100644 src/apps/gas-tank/components/Misc/Tooltip.tsx create mode 100644 src/apps/gas-tank/components/Price/TokenPrice.tsx create mode 100644 src/apps/gas-tank/components/Price/TokenPriceChange.tsx create mode 100644 src/apps/gas-tank/components/Search/ChainOverlay.tsx create mode 100644 src/apps/gas-tank/components/Search/ChainSelect.tsx create mode 100644 src/apps/gas-tank/components/Search/PortfolioTokenList.tsx create mode 100644 src/apps/gas-tank/components/Search/Search.tsx create mode 100644 src/apps/gas-tank/components/Search/Sort.tsx create mode 100644 src/apps/gas-tank/components/Search/TokenList.tsx create mode 100644 src/apps/gas-tank/components/Search/tests/PortfolioTokenList.test.tsx create mode 100644 src/apps/gas-tank/components/Search/tests/Search.test.tsx create mode 100644 src/apps/gas-tank/components/Search/tests/__snapshots__/PortfolioTokenList.test.tsx.snap create mode 100644 src/apps/gas-tank/components/Search/tests/__snapshots__/Search.test.tsx.snap create mode 100644 src/apps/gas-tank/components/UniversalGasTank.styles.ts create mode 100644 src/apps/gas-tank/components/shared.styles.ts create mode 100644 src/apps/gas-tank/constants/tokens.ts create mode 100644 src/apps/gas-tank/hooks/useTokenSearch.ts create mode 100644 src/apps/gas-tank/types/tokens.ts create mode 100644 src/apps/gas-tank/types/types.ts create mode 100644 src/apps/gas-tank/utils/constants.ts create mode 100644 src/apps/gas-tank/utils/number.ts create mode 100644 src/apps/gas-tank/utils/parseSearchData.ts create mode 100644 src/apps/gas-tank/utils/time.ts create mode 100644 src/apps/pillarx-app/components/GasTankPaymasterTile/GasTankPaymasterTile.tsx diff --git a/package-lock.json b/package-lock.json index df060d31..a27dc51e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "moment": "^2.30.1", "patch-package": "^8.0.0", "plausible-tracker": "^0.3.9", + "polished": "^4.3.1", "prettier": "^3.3.3", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -71,6 +72,7 @@ "react-i18next": "^13.4.1", "react-icons": "^4.12.0", "react-intersection-observer": "^9.13.1", + "react-loader-spinner": "^7.0.3", "react-redux": "^9.1.2", "react-router-dom": "^6.18.0", "react-slick": "^0.30.2", @@ -5808,74 +5810,6 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -9778,14 +9712,6 @@ "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==" }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -10686,16 +10612,6 @@ "react-dom": ">=16.8" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10" - } - }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -11263,17 +11179,6 @@ "@types/node": "*" } }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/@types/yargs-parser": { "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", @@ -12199,18 +12104,6 @@ "preact": "^10.24.2" } }, - "node_modules/@wagmi/connectors/node_modules/@noble/hashes": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", - "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@wagmi/connectors/node_modules/@safe-global/safe-apps-provider": { "version": "0.18.5", "resolved": "https://registry.npmjs.org/@safe-global/safe-apps-provider/-/safe-apps-provider-0.18.5.tgz", @@ -14181,7 +14074,7 @@ "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "deprecated": "Use your platform's native atob() and btoa() methods instead", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/abitype": { @@ -14250,17 +14143,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -14269,19 +14151,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/address": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", @@ -16826,18 +16695,11 @@ "node": ">=0.10.0" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "optional": true, - "peer": true - }, "node_modules/cssstyle": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "cssom": "~0.3.6" @@ -16850,7 +16712,7 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/csstype": { @@ -16874,21 +16736,6 @@ "node": ">=0.10" } }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "optional": true, - "peer": true, - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -16979,7 +16826,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/decode-uri-component": { @@ -17338,20 +17185,6 @@ ], "peer": true }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "optional": true, - "peer": true, - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", @@ -17659,19 +17492,6 @@ "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", "integrity": "sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==" }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -17975,7 +17795,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "esprima": "^4.0.1", @@ -17993,16 +17813,6 @@ "source-map": "~0.6.1" } }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -19147,7 +18957,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "devOptional": true, + "dev": true, "peer": true, "bin": { "esparse": "bin/esparse.js", @@ -21109,19 +20919,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -21291,21 +21088,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "optional": true, - "peer": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/http-proxy-middleware": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", @@ -22077,7 +21859,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/is-regex": { @@ -22535,44 +22317,6 @@ } } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, "node_modules/jest-jasmine2": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", @@ -23231,74 +22975,6 @@ } } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-serializer": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", @@ -23328,203 +23004,6 @@ "styled-components": ">= 5" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -23594,52 +23073,6 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "optional": true, - "peer": true, - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -24758,7 +24191,7 @@ "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/oauth-sign": { @@ -25234,19 +24667,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "optional": true, - "peer": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -25727,6 +25147,18 @@ "node": ">=10.13.0" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pony-cause": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", @@ -27513,7 +26945,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/queue-microtask": { @@ -27966,6 +27398,23 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==" }, + "node_modules/react-loader-spinner": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-7.0.3.tgz", + "integrity": "sha512-N29VGq7pPHH1/TqDNKc04ij0uFtJpyM2i6M5vXOcbOfrfl7KENguD4SYkrWBNfwpILHfAPqrRGSPrqoQvLOJsQ==", + "license": "MIT", + "dependencies": { + "styled-components": "^6.1.19", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": ">=17.0.0 <20.0.0", + "react-dom": ">=17.0.0 <20.0.0" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -30410,7 +29859,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/reselect": { @@ -30545,17 +29994,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/restore-cursor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", @@ -30891,19 +30329,6 @@ "dev": true, "peer": true }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "optional": true, - "peer": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -31786,17 +31211,6 @@ "node": ">= 0.8.0" } }, - "node_modules/static-eval/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/static-eval/node_modules/type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -32588,7 +32002,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/synckit": { @@ -33086,7 +32500,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "psl": "^1.1.33", @@ -33098,19 +32512,6 @@ "node": ">=6" } }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "optional": true, - "peer": true, - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -33490,7 +32891,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "devOptional": true, + "dev": true, "peer": true, "engines": { "node": ">= 4.0.0" @@ -33607,7 +33008,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "devOptional": true, + "dev": true, "peer": true, "dependencies": { "querystringify": "^2.1.1", @@ -34527,19 +33928,6 @@ "browser-process-hrtime": "^1.0.0" } }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "optional": true, - "peer": true, - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/wagmi": { "version": "2.14.16", "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.14.16.tgz", @@ -34633,16 +34021,6 @@ "resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz", "integrity": "sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/webpack": { "version": "5.99.9", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", @@ -34946,19 +34324,6 @@ "node": ">=0.8.0" } }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", @@ -34966,30 +34331,6 @@ "dev": true, "peer": true }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -35573,21 +34914,11 @@ } } }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "devOptional": true, + "dev": true, "peer": true }, "node_modules/xmlhttprequest-ssl": { diff --git a/package.json b/package.json index f527644e..9dd583d0 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "moment": "^2.30.1", "patch-package": "^8.0.0", "plausible-tracker": "^0.3.9", + "polished": "^4.3.1", "prettier": "^3.3.3", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -79,6 +80,7 @@ "react-i18next": "^13.4.1", "react-icons": "^4.12.0", "react-intersection-observer": "^9.13.1", + "react-loader-spinner": "^7.0.3", "react-redux": "^9.1.2", "react-router-dom": "^6.18.0", "react-slick": "^0.30.2", diff --git a/src/apps/gas-tank/assets/arrow-down.svg b/src/apps/gas-tank/assets/arrow-down.svg new file mode 100644 index 00000000..2fb59a3e --- /dev/null +++ b/src/apps/gas-tank/assets/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/gas-tank/assets/close-icon.svg b/src/apps/gas-tank/assets/close-icon.svg new file mode 100644 index 00000000..d5e1a045 --- /dev/null +++ b/src/apps/gas-tank/assets/close-icon.svg @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/confirmed-icon.svg b/src/apps/gas-tank/assets/confirmed-icon.svg new file mode 100644 index 00000000..c7179de5 --- /dev/null +++ b/src/apps/gas-tank/assets/confirmed-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/gas-tank/assets/copy-icon.svg b/src/apps/gas-tank/assets/copy-icon.svg new file mode 100644 index 00000000..7d5830a6 --- /dev/null +++ b/src/apps/gas-tank/assets/copy-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/apps/gas-tank/assets/esc-icon.svg b/src/apps/gas-tank/assets/esc-icon.svg new file mode 100644 index 00000000..63d44133 --- /dev/null +++ b/src/apps/gas-tank/assets/esc-icon.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/failed-icon.svg b/src/apps/gas-tank/assets/failed-icon.svg new file mode 100644 index 00000000..acfb9313 --- /dev/null +++ b/src/apps/gas-tank/assets/failed-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/gas-tank/assets/gas-tank-icon.png b/src/apps/gas-tank/assets/gas-tank-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e86f5f68f213a8bd2cfe57b666e45bcc86889d71 GIT binary patch literal 7527 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 FzW@mspEdvh literal 0 HcmV?d00001 diff --git a/src/apps/gas-tank/assets/globe-icon.svg b/src/apps/gas-tank/assets/globe-icon.svg new file mode 100644 index 00000000..3c7edee2 --- /dev/null +++ b/src/apps/gas-tank/assets/globe-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/gas-tank/assets/moreinfo-icon.svg b/src/apps/gas-tank/assets/moreinfo-icon.svg new file mode 100644 index 00000000..592b4f9c --- /dev/null +++ b/src/apps/gas-tank/assets/moreinfo-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/apps/gas-tank/assets/new-tab.svg b/src/apps/gas-tank/assets/new-tab.svg new file mode 100644 index 00000000..10f73717 --- /dev/null +++ b/src/apps/gas-tank/assets/new-tab.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/pending.svg b/src/apps/gas-tank/assets/pending.svg new file mode 100644 index 00000000..f3076cfd --- /dev/null +++ b/src/apps/gas-tank/assets/pending.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/gas-tank/assets/refresh-icon.svg b/src/apps/gas-tank/assets/refresh-icon.svg new file mode 100644 index 00000000..42bddd84 --- /dev/null +++ b/src/apps/gas-tank/assets/refresh-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/apps/gas-tank/assets/seach-icon.svg b/src/apps/gas-tank/assets/seach-icon.svg new file mode 100644 index 00000000..a1853e4f --- /dev/null +++ b/src/apps/gas-tank/assets/seach-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/apps/gas-tank/assets/selected-icon.svg b/src/apps/gas-tank/assets/selected-icon.svg new file mode 100644 index 00000000..e7d55d85 --- /dev/null +++ b/src/apps/gas-tank/assets/selected-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/apps/gas-tank/assets/setting-icon.svg b/src/apps/gas-tank/assets/setting-icon.svg new file mode 100644 index 00000000..9adc8527 --- /dev/null +++ b/src/apps/gas-tank/assets/setting-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/gas-tank/assets/transaction-failed-details-icon.svg b/src/apps/gas-tank/assets/transaction-failed-details-icon.svg new file mode 100644 index 00000000..d88584f3 --- /dev/null +++ b/src/apps/gas-tank/assets/transaction-failed-details-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/gas-tank/assets/usd-coin-usdc-logo.png b/src/apps/gas-tank/assets/usd-coin-usdc-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..452726dab6b209390fe3d43637408fa7e3b8ce48 GIT binary patch literal 23558 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 z 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 literal 0 HcmV?d00001 diff --git a/src/apps/gas-tank/assets/wallet.svg b/src/apps/gas-tank/assets/wallet.svg new file mode 100644 index 00000000..d81b5262 --- /dev/null +++ b/src/apps/gas-tank/assets/wallet.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/apps/gas-tank/assets/warning.svg b/src/apps/gas-tank/assets/warning.svg new file mode 100644 index 00000000..9a5ae4a9 --- /dev/null +++ b/src/apps/gas-tank/assets/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/gas-tank/components/GasTank.tsx b/src/apps/gas-tank/components/GasTank.tsx index 2e307256..c40e6136 100644 --- a/src/apps/gas-tank/components/GasTank.tsx +++ b/src/apps/gas-tank/components/GasTank.tsx @@ -8,42 +8,18 @@ import GasTankHistory from './GasTankHistory'; const GasTank = () => { return ( - - - - - - + ); }; const Container = styled.div` - display: flex; - gap: 40px; - max-width: 1200px; margin: 0 auto; padding: 32px; @media (max-width: 768px) { - flex-direction: column; - gap: 24px; padding: 16px; } `; -const LeftSection = styled.div` - flex: 1; - max-width: 400px; - - @media (max-width: 768px) { - max-width: 100%; - } -`; - -const RightSection = styled.div` - flex: 2; - min-width: 0; -`; - export default GasTank; diff --git a/src/apps/gas-tank/components/GasTankHistory.styles.ts b/src/apps/gas-tank/components/GasTankHistory.styles.ts new file mode 100644 index 00000000..89a85dcd --- /dev/null +++ b/src/apps/gas-tank/components/GasTankHistory.styles.ts @@ -0,0 +1,289 @@ +import styled from 'styled-components'; +import { BaseContainer, colors, typography } from './shared.styles'; + +import { DetailedHTMLProps, HTMLAttributes } from 'react'; + +interface StyledProps extends DetailedHTMLProps, HTMLDivElement> { + $isDeposit: boolean; +} + +export const S = { + Loading: styled.div` + color: ${colors.text.secondary}; + font-size: 14px; + text-align: center; + padding: 24px 0; + `, + + Container: styled.div` + background: transparent; + border: none; + border-radius: 0; + padding: 0; + width: 100%; + color: ${colors.text.primary}; + `, + + TableWrapper: styled.div` + max-height: 400px; + overflow-y: auto; + padding: 0; + + /* Custom scrollbar styles */ + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.3s ease; + + &:hover { + scrollbar-color: ${colors.button.primary} ${colors.background}; + } + + /* WebKit scrollbar styles */ + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 4px; + transition: background 0.3s ease, opacity 0.3s ease; + } + + &:hover::-webkit-scrollbar-thumb { + background: ${colors.button.primary}; + } + + &::-webkit-scrollbar-thumb:hover { + background: ${colors.button.primaryHover}; + } + + /* Auto-hide behavior */ + &:not(:hover)::-webkit-scrollbar-thumb { + opacity: 0; + transition: opacity 0.5s ease 1s; + } + `, + + Header: styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid ${colors.border}; + `, + + Icon: styled.span` + font-size: 18px; + `, + + IconImage: styled.img` + width: 18px; + height: 18px; + object-fit: contain; + `, + + Title: styled.h2` + ${typography.title}; + margin: 0; + `, + + RefreshButton: styled.button` + margin-left: auto; + background: none; + border: none; + color: ${colors.text.secondary}; + font-size: 18px; + cursor: pointer; + padding: 0 4px; + transition: color 0.2s; + &:hover { + color: ${colors.text.primary}; + } + `, + + TableHeader: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 24px; + padding: 12px 16px; + border-bottom: 1px solid ${colors.border}; + cursor: pointer; + `, + + HeaderCell: styled.div` + ${typography.small}; + text-transform: uppercase; + letter-spacing: 0.5px; + display: flex; + align-items: center; + gap: 4px; + user-select: none; + + &:first-child { + justify-content: center; /* # column */ + } + + &:nth-child(2) { + justify-content: flex-start; /* Date column */ + } + + &:nth-child(3) { + justify-content: center; /* Type column */ + } + + &:nth-child(4) { + justify-content: center; /* Amount column */ + } + + &:nth-child(5) { + justify-content: flex-start; /* Token column */ + } + `, + + TableBody: styled.div` + width: 100%; + `, + + SortIcon: styled.span` + font-size: 12px; + margin-left: 2px; + `, + + TableRow: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 24px; + padding: 12px 16px; + border-bottom: 1px solid ${colors.border}; + transition: background-color 0.2s; + align-items: center; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: ${({ $isDeposit }) => + $isDeposit + ? 'rgba(74, 222, 128, 0.1)' // Success color with opacity + : 'rgba(239, 68, 68, 0.1)'}; // Error color with opacity + } + `, + + IdCell: styled.div` + color: ${colors.text.primary}; + font-size: 14px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + `, + + DateCell: styled.div` + color: ${colors.text.primary}; + font-size: 14px; + display: flex; + align-items: center; + justify-content: flex-start; + `, + + TypeCell: styled.div` + color: ${colors.text.primary}; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + `, + + AmountCell: styled.div` + color: ${({ $isDeposit }) => + $isDeposit ? colors.status.success : colors.status.error}; + background: none; + padding: 4px 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + ${typography.body}; + font-weight: 600; + `, + + TokenCell: styled.div` + display: flex; + align-items: center; + gap: 8px; + `, + + TokenIconContainer: styled.div` + position: relative; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + `, + + TokenIcon: styled.img` + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + background: ${colors.background}; + `, + + ChainOverlay: styled.img` + position: absolute; + bottom: -2px; + right: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + object-fit: cover; + background: #fff; + border: 1px solid #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + `, + + TokenInfo: styled.div` + display: flex; + flex-direction: column; + gap: 2px; + `, + + TokenValue: styled.span` + color: ${colors.text.primary}; + font-size: 14px; + font-weight: 600; + `, + + TokenSymbol: styled.span` + color: ${colors.text.secondary}; + font-size: 12px; + `, + + NoItemsMsg: styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + color: ${colors.text.secondary}; + font-size: 14px; + font-style: italic; + `, + + ErrorMsg: styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 32px; + color: ${colors.status.error}; + font-size: 14px; + `, +}; \ No newline at end of file diff --git a/src/apps/gas-tank/components/GasTankHistory.tsx b/src/apps/gas-tank/components/GasTankHistory.tsx index 6d05bd31..c1ab9206 100644 --- a/src/apps/gas-tank/components/GasTankHistory.tsx +++ b/src/apps/gas-tank/components/GasTankHistory.tsx @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { useEffect, useState, useCallback } from 'react'; -import styled from 'styled-components'; import { useWalletAddress } from '@etherspot/transaction-kit'; import { formatUnits } from 'viem'; +import { S } from './GasTankHistory.styles'; // Import chain logos import logoArbitrum from '../../../assets/images/logo-arbitrum.png'; @@ -15,6 +15,9 @@ import logoOptimism from '../../../assets/images/logo-optimism.png'; import logoPolygon from '../../../assets/images/logo-polygon.png'; import logoUnknown from '../../../assets/images/logo-unknown.png'; +// assets +import gasTankIcon from '../assets/gas-tank-icon.png'; + /** * Represents a single entry in the gas tank history table. */ @@ -68,34 +71,67 @@ const getChainLogo = (chainId: string): string => { } }; +interface GasTankHistoryProps { + pauseAutoRefresh?: boolean; + overrideLoading?: boolean; + historyData?: HistoryEntry[]; + isLoading?: boolean; + isError?: boolean; + onRefresh?: () => void; +} + /** * GasTankHistory component * Displays a sortable, scrollable table of gas tank transaction history for the connected wallet. * Handles loading, error, and empty states. Allows manual refresh. */ -const GasTankHistory = () => { +const GasTankHistory = ({ + pauseAutoRefresh = false, + overrideLoading = true, + historyData: externalHistoryData, + isLoading: externalIsLoading, + isError: externalIsError, + onRefresh: externalOnRefresh +}: GasTankHistoryProps) => { const walletAddress = useWalletAddress(); - const [historyData, setHistoryData] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); // Error state for API failures + const [internalHistoryData, setInternalHistoryData] = useState([]); + const [internalLoading, setInternalLoading] = useState(true); + const [internalError, setInternalError] = useState(false); const [sortKey, setSortKey] = useState(null); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + // Use external data if provided, otherwise use internal data + const historyData = externalHistoryData || internalHistoryData; + const loading = externalIsLoading !== undefined ? externalIsLoading : internalLoading; + const error = externalIsError !== undefined ? externalIsError : internalError; + /** * Fetches history data from the REST API and updates state. * Handles error and loading states. */ const fetchHistory = useCallback(() => { + // If external refresh is available, use it instead + if (externalOnRefresh) { + externalOnRefresh(); + return; + } + if (!walletAddress) return; - setLoading(true); - setError(false); // Reset error before fetching + setInternalLoading(true); + setInternalError(false); // Reset error before fetching fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }) - .then((res) => res.json()) + .then((res) => { + console.log('Main component response status:', res.status); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) .then((data) => { // Map API response to HistoryEntry structure const entries: HistoryEntry[] = (data.history || []).map((item: any, idx: number) => { @@ -106,43 +142,45 @@ const GasTankHistory = () => { type: isDeposit ? 'Top-up' : 'Spend', amount: formatAmount(isDeposit ? item.amountUsd : item.amount, isDeposit), token: { - symbol: isDeposit ? item.swap[0].asset.symbol : 'USDC', - value: isDeposit ? item.amount : formatTokenValue(item.amount), - icon: isDeposit - ? (item.swap && item.swap.length > 0 - ? item.swap[0].asset.logo + symbol: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.swap[0].asset.symbol : 'USDC', + value: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.amount : formatTokenValue(item.amount), + icon: isDeposit + ? (item.swap && item.swap.length > 0 && item.swap[0] + ? item.swap[0].asset.logo : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694') : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694', chainId: item.chainId || '1', }, }; }); - setHistoryData(entries); + setInternalHistoryData(entries); }) .catch((err) => { - setHistoryData([]); - console.error('Error fetching gas tank history:', err); - setError(true); // Set error on failure + console.error('Main component error fetching gas tank history:', err); + setInternalHistoryData([]); + setInternalError(true); // Set error on failure }) - .finally(() => setLoading(false)); - }, [walletAddress]); + .finally(() => setInternalLoading(false)); + }, [walletAddress, externalOnRefresh]); - // Fetch history on wallet address change + // Fetch history on wallet address change (only if no external data provided) useEffect(() => { - fetchHistory(); - }, [fetchHistory]); + if (!externalHistoryData) { + fetchHistory(); + } + }, [fetchHistory, externalHistoryData]); - // Auto-refresh every 30 seconds when component is active - useEffect(() => { - if (!walletAddress) return; + // Auto-refresh disabled + // useEffect(() => { + // if (!walletAddress || pauseAutoRefresh) return; - const interval = setInterval(() => { - fetchHistory(); - }, 30000); // 30 seconds + // const interval = setInterval(() => { + // fetchHistory(); + // }, 30000); // 30 seconds - // Cleanup interval on component unmount - return () => clearInterval(interval); - }, [walletAddress, fetchHistory]); + // // Cleanup interval on component unmount + // return () => clearInterval(interval); + // }, [walletAddress, fetchHistory, pauseAutoRefresh]); /** * Returns the sort icon for a given column key. @@ -202,82 +240,80 @@ const GasTankHistory = () => { }); return ( - + {/* Table header with refresh button */} -
- 📋 - Gas Tank History - + + + Gas Tank History + 🔄 - -
+ + {/* Table content: loading, error, empty, or data */} - {loading ? ( - Loading... + {(loading && overrideLoading) ? ( + Loading... ) : ( - - - - handleSort('id')}> + + + + handleSort('id')}> # - {getSortIcon('id')} - - handleSort('date')}> + {getSortIcon('id')} + + handleSort('date')}> Date - {getSortIcon('date')} - - handleSort('type')}> + {getSortIcon('date')} + + handleSort('type')}> Type - {getSortIcon('type')} - - handleSort('amount')}> + {getSortIcon('type')} + + handleSort('amount')}> Amount - {getSortIcon('amount')} - - handleSort('token')}> + {getSortIcon('amount')} + + handleSort('token')}> Token - {getSortIcon('token')} - - - - - {error ? ( - // Error message if API call fails - - Error has occurred while fetching. Please try after some time - - ) : sortedHistory.length === 0 ? ( - // Empty message if no data - No items to display - ) : ( - // Render table rows for each entry - sortedHistory.map((entry) => ( - - {entry.id} - {entry.date} - {entry.type} - - {entry.amount} - - - - - - - - {entry.token.value} - {entry.token.symbol} - - - - )) - )} - -
-
+ {getSortIcon('token')} + + + + {error ? ( + // Error message if API call fails + + Error has occurred while fetching. Please try after some time + + ) : sortedHistory.length === 0 ? ( + // Empty message if no data + No items to display + ) : ( + // Render table rows for each entry + sortedHistory.map((entry) => ( + + {entry.id} + {entry.date} + {entry.type} + + {entry.amount} + + + + + + + + {entry.token.value} + {entry.token.symbol} + + + + )) + )} + + )} -
+ ); }; @@ -308,13 +344,28 @@ function formatAmount(amount: string, isDeposit: boolean): string { * Formats the token value using USDC decimals (6). */ function formatTokenValue(amount: string): string { - return formatUnits(BigInt(amount), 6); + try { + // Check if the amount is already a decimal (contains a dot) + if (amount.includes('.')) { + return parseFloat(amount).toFixed(6); + } + // If it's a whole number string, treat it as wei-like format + return formatUnits(BigInt(amount), 6); + } catch (error) { + // Fallback: treat as regular decimal number + return parseFloat(amount).toFixed(6); + } } /** * Custom hook to fetch and expose gas tank history and total spend. */ -export function useGasTankHistory(walletAddress: string | undefined) { +interface UseGasTankHistoryOptions { + pauseAutoRefresh?: boolean; +} + +export function useGasTankHistory(walletAddress: string | undefined, options: UseGasTankHistoryOptions = {}) { + const { pauseAutoRefresh = false } = options; const [historyData, setHistoryData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -325,13 +376,20 @@ export function useGasTankHistory(walletAddress: string | undefined) { if (!walletAddress) return; setLoading(true); setError(false); + fetch(`${API_URL}/getGasTankHistory?sender=${walletAddress}`, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }) - .then((res) => res.json()) + .then((res) => { + console.log('Response status:', res.status); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) .then((data) => { const entries: HistoryEntry[] = (data.history || []).map((item: any, idx: number) => { const isDeposit = item.transactionType === 'Deposit'; @@ -341,11 +399,11 @@ export function useGasTankHistory(walletAddress: string | undefined) { type: isDeposit ? 'Top-up' : 'Spend', amount: formatAmount(isDeposit ? item.amountUsd : item.amount, isDeposit), token: { - symbol: item.swap ? item.swap.asset.symbol : 'USDC', - value: item.swap ? item.amount : formatTokenValue(item.amount), - icon: isDeposit - ? (item.swap && item.swap.length > 0 - ? item.swap[0].asset.logo + symbol: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.swap[0].asset.symbol : 'USDC', + value: (item.swap && item.swap.length > 0 && item.swap[0]) ? item.amount : formatTokenValue(item.amount), + icon: isDeposit + ? (item.swap && item.swap.length > 0 && item.swap[0] + ? item.swap[0].asset.logo : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694') : 'https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694', chainId: item.chainId || '1', @@ -359,7 +417,8 @@ export function useGasTankHistory(walletAddress: string | undefined) { .reduce((acc, entry) => acc + Number(entry.amount.replace(/[^0-9.-]+/g, '')), 0); setTotalSpend(Math.abs(totalSpendCal)); }) - .catch(() => { + .catch((err) => { + console.error('Error fetching gas tank history:', err); setHistoryData([]); setError(true); }) @@ -371,247 +430,22 @@ export function useGasTankHistory(walletAddress: string | undefined) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletAddress]); - return { historyData, loading, error, totalSpend, refetch: fetchHistory }; -} - -// Styled-components for layout and table styling - -const Loading = styled.div` - color: #9ca3af; - font-size: 14px; - text-align: center; - padding: 24px 0; -`; - -const Container = styled.div` - background: #1a1a1a; - border-radius: 16px; - padding: 24px; - border: 1px solid #333; -`; - -const Header = styled.div` - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 24px; -`; - -const Icon = styled.span` - font-size: 18px; -`; - -const Title = styled.h2` - color: #ffffff; - font-size: 18px; - font-weight: 600; - margin: 0; -`; - -const TableWrapper = styled.div` - max-height: 340px; - overflow-y: auto; - - /* Custom scrollbar styles */ - scrollbar-width: thin; - scrollbar-color: transparent transparent; - transition: scrollbar-color 0.3s ease; - - &:hover { - scrollbar-color: #7c3aed #2a2a2a; - } - - /* WebKit scrollbar styles */ - &::-webkit-scrollbar { - width: 8px; - } - - &::-webkit-scrollbar-track { - background: transparent; - border-radius: 4px; - } + // Auto-refresh disabled + // useEffect(() => { + // if (!walletAddress || pauseAutoRefresh) return; - &::-webkit-scrollbar-thumb { - background: transparent; - border-radius: 4px; - transition: background 0.3s ease, opacity 0.3s ease; - } + // const interval = setInterval(() => { + // fetchHistory(); + // }, 30000); // 30 seconds - &:hover::-webkit-scrollbar-thumb { - background: #7c3aed; - } - - &::-webkit-scrollbar-thumb:hover { - background: #8b5cf6; - } + // // Cleanup interval on component unmount + // return () => clearInterval(interval); + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [walletAddress, pauseAutoRefresh]); - /* Auto-hide behavior */ - &:not(:hover)::-webkit-scrollbar-thumb { - opacity: 0; - transition: opacity 0.5s ease 1s; - } -`; - -const Table = styled.div` - width: 100%; -`; - -const TableHeader = styled.div` - display: grid; - grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; - gap: 16px; - padding-bottom: 12px; - border-bottom: 1px solid #333; - margin-bottom: 8px; - cursor: pointer; -`; - -const HeaderCell = styled.div` - color: #9ca3af; - font-size: 12px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - display: flex; - align-items: center; - gap: 4px; - user-select: none; -`; - -const SortIcon = styled.span` - font-size: 12px; - margin-left: 2px; -`; - -const TableBody = styled.div` - display: flex; - flex-direction: column; - gap: 8px; -`; - -const TableRow = styled.div` - display: grid; - grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; - gap: 16px; - padding: 12px 0; - border-bottom: 1px solid #2a2a2a; - align-items: center; - - &:last-child { - border-bottom: none; - } -`; - -const IdCell = styled.div` - color: #ffffff; - font-size: 14px; - text-align: center; -`; - -const DateCell = styled.div` - color: #ffffff; - font-size: 14px; -`; - -const TypeCell = styled.div` - color: #ffffff; - font-size: 14px; -`; - -const AmountCell = styled.div<{ isPositive: boolean }>` - color: ${(props) => (props.isPositive ? '#4ade80' : '#ef4444')}; - font-size: 14px; - font-weight: 600; -`; - -const TokenCell = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; - -const TokenIconContainer = styled.div` - position: relative; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -`; - -const TokenIcon = styled.img` - width: 24px; - height: 24px; - border-radius: 50%; - object-fit: cover; - background: #2a2a2a; -`; - -const ChainOverlay = styled.img` - position: absolute; - bottom: -2px; - right: -2px; - width: 12px; - height: 12px; - border-radius: 50%; - object-fit: cover; - background: #fff; - border: 1px solid #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); -`; - -const TokenInfo = styled.div` - display: flex; - align-items: center; - gap: 4px; -`; - -const TokenValue = styled.span` - color: #ffffff; - font-size: 14px; - font-weight: 600; -`; - -const TokenSymbol = styled.span` - color: #9ca3af; - font-size: 12px; -`; - -/** - * Refresh button for manually refetching history data. - */ -const RefreshButton = styled.button` - margin-left: auto; - background: none; - border: none; - color: #9ca3af; - font-size: 18px; - cursor: pointer; - padding: 0 4px; - transition: color 0.2s; - &:hover { - color: #fff; - } -`; - -/** - * Message shown when there are no items to display. - */ -const NoItemsMsg = styled.div` - color: #9ca3af; - font-size: 16px; - text-align: center; - padding: 48px 0; -`; + return { historyData, loading, error, totalSpend, refetch: fetchHistory }; +} -/** - * Message shown when an error occurs while fetching data. - */ -const ErrorMsg = styled.div` - color: #ef4444; - font-size: 16px; - text-align: center; - padding: 48px 0; -`; +// All styled components moved to GasTankHistory.styles.ts export default GasTankHistory; diff --git a/src/apps/gas-tank/components/Misc/Close.tsx b/src/apps/gas-tank/components/Misc/Close.tsx new file mode 100644 index 00000000..71bbd179 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Close.tsx @@ -0,0 +1,15 @@ +import CloseIcon from '../../assets/close-icon.svg'; + +export default function Close(props: { onClose: () => void }) { + const { onClose } = props; + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/CloseButton.tsx b/src/apps/gas-tank/components/Misc/CloseButton.tsx new file mode 100644 index 00000000..b3e2798d --- /dev/null +++ b/src/apps/gas-tank/components/Misc/CloseButton.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; + +interface CloseButtonProps { + onClose: () => void; +} + +export default function CloseButton(props: CloseButtonProps) { + const { onClose } = props; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Esc.tsx b/src/apps/gas-tank/components/Misc/Esc.tsx new file mode 100644 index 00000000..7fa8f855 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Esc.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; + +interface EscProps { + onClose: () => void; +} + +export default function Esc(props: EscProps) { + const { onClose } = props; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Refresh.tsx b/src/apps/gas-tank/components/Misc/Refresh.tsx new file mode 100644 index 00000000..7138cc84 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Refresh.tsx @@ -0,0 +1,34 @@ +import { TailSpin } from 'react-loader-spinner'; +import RefreshIcon from '../../assets/refresh-icon.svg'; + +interface RefreshProps { + onClick?: () => void; + disabled?: boolean; + isLoading?: boolean; +} + +export default function Refresh({ + onClick, + disabled = false, + isLoading = false, +}: RefreshProps) { + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Settings.tsx b/src/apps/gas-tank/components/Misc/Settings.tsx new file mode 100644 index 00000000..29824444 --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Settings.tsx @@ -0,0 +1,13 @@ +import SettingsIcon from '../../assets/setting-icon.svg'; + +export default function Settings() { + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Misc/Tooltip.tsx b/src/apps/gas-tank/components/Misc/Tooltip.tsx new file mode 100644 index 00000000..ce4363fb --- /dev/null +++ b/src/apps/gas-tank/components/Misc/Tooltip.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react'; + +interface TooltipProps { + children: React.ReactNode; + content: string; +} + +const Tooltip = ({ children, content }: TooltipProps) => { + const [isVisible, setIsVisible] = useState(false); + const [isPositioned, setIsPositioned] = useState(false); + const [position, setPosition] = useState({ + top: 0, + left: 0, + transform: 'translateX(-50%)', + }); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + + const calculatePosition = () => { + if (!triggerRef.current || !tooltipRef.current) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const tooltipWidth = tooltipRect.width; + const tooltipHeight = tooltipRect.height; + + // Calculate ideal center position + const idealLeft = triggerRect.left + triggerRect.width / 2; + const idealTop = triggerRect.top - tooltipHeight - 8; + + // Calculate boundaries + const margin = 10; + const minLeft = margin; + const maxLeft = viewportWidth - tooltipWidth - margin; + const minTop = margin; + + let finalLeft = idealLeft; + let finalTop = idealTop; + let transform = 'translateX(-50%)'; + + // Adjust horizontal position if tooltip would overflow + if (idealLeft - tooltipWidth / 2 < minLeft) { + // Too far left - align to left edge + finalLeft = minLeft; + transform = 'translateX(0)'; + } else if (idealLeft + tooltipWidth / 2 > viewportWidth - margin) { + // Too far right - align to right edge + finalLeft = maxLeft; + transform = 'translateX(0)'; + } + + // Adjust vertical position if tooltip would overflow above + if (idealTop < minTop) { + // Not enough space above - position below trigger + finalTop = triggerRect.bottom + 8; + transform = transform + .replace('translateX', 'translateX') + .replace('translateY', 'translateY'); + } + + setPosition({ + top: finalTop, + left: finalLeft, + transform, + }); + setIsPositioned(true); + }; + + const handleMouseEnter = () => { + setIsVisible(true); + }; + + const handleMouseLeave = () => { + setIsVisible(false); + setIsPositioned(false); + }; + + useEffect(() => { + if (isVisible && tooltipRef.current) { + // Calculate position after tooltip is rendered + calculatePosition(); + } + }, [isVisible]); + + return ( +
+
+ {children} +
+ {isVisible && ( +
+
+ {content} +
+
+ )} +
+ ); +}; + +export default Tooltip; diff --git a/src/apps/gas-tank/components/Price/TokenPrice.tsx b/src/apps/gas-tank/components/Price/TokenPrice.tsx new file mode 100644 index 00000000..30f31373 --- /dev/null +++ b/src/apps/gas-tank/components/Price/TokenPrice.tsx @@ -0,0 +1,33 @@ +export interface TokenPriceProps { + value: number; +} + +export default function TokenPrice(props: TokenPriceProps): JSX.Element { + const { value } = props; + const fixed = value.toFixed(10); + const parts = fixed.split('.'); + + const decimals = parts[1]; + const firstNonZeroIndex = decimals.search(/[^0]/); + + if (firstNonZeroIndex < 0) { + return

$0.00

; + } + + if (value >= 0.01 || firstNonZeroIndex < 2) { + return

${value.toFixed(5)}

; + } + + const leadingZerosCount = firstNonZeroIndex; + const significantDigits = decimals.slice( + firstNonZeroIndex, + firstNonZeroIndex + 4 + ); + + return ( +

+ $0.0{leadingZerosCount} + {significantDigits} +

+ ); +} diff --git a/src/apps/gas-tank/components/Price/TokenPriceChange.tsx b/src/apps/gas-tank/components/Price/TokenPriceChange.tsx new file mode 100644 index 00000000..a9b05b4e --- /dev/null +++ b/src/apps/gas-tank/components/Price/TokenPriceChange.tsx @@ -0,0 +1,56 @@ +export interface TokenPriceChangeProps { + value: number; +} + +export default function TokenPriceChange( + props: TokenPriceChangeProps +): JSX.Element { + const { value } = props; + const green = ( + + + + ); + + const red = ( + + + + ); + + return ( +
+
0 ? '#5CFF93' : '#FF366C', + }} + > + {value > 0 ? green : red} +

{value < 0 ? (value * -1).toFixed(2) : value.toFixed(2)}%

+
+
+ ); +} diff --git a/src/apps/gas-tank/components/Search/ChainOverlay.tsx b/src/apps/gas-tank/components/Search/ChainOverlay.tsx new file mode 100644 index 00000000..de73f994 --- /dev/null +++ b/src/apps/gas-tank/components/Search/ChainOverlay.tsx @@ -0,0 +1,111 @@ +import { chainNameToChainIdTokensData } from '../../../../services/tokensData'; +import { getLogoForChainId } from '../../../../utils/blockchain'; +import { MobulaChainNames } from '../../utils/constants'; +import GlobeIcon from '../../assets/globe-icon.svg'; +import SelectedIcon from '../../assets/selected-icon.svg'; + +export interface ChainOverlayProps { + setShowChainOverlay: React.Dispatch>; + setOverlayStyle: React.Dispatch>; + setChains: React.Dispatch>; + overlayStyle: React.CSSProperties; + chains: MobulaChainNames; +} + +export default function ChainOverlay(chainOverlayProps: ChainOverlayProps) { + const { + setShowChainOverlay, + setChains, + setOverlayStyle, + overlayStyle, + chains, + } = chainOverlayProps; + return ( + <> +
{ + setShowChainOverlay(false); + setOverlayStyle({}); + }} + /> +
e.stopPropagation()}> +
+ {Object.values(MobulaChainNames).map((chain) => { + const isSelected = chains === chain; + const isAll = chain === MobulaChainNames.All; + let logo = null; + if (isAll) { + logo = ( + + globe-icon + + ); + } else { + const chainId = chainNameToChainIdTokensData(chain); + logo = ( + {chain} + ); + } + return ( +
{ + setChains(chain); + setShowChainOverlay(false); + setOverlayStyle({}); + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '10px 18px', + cursor: 'pointer', + background: isSelected ? '#29292F' : 'transparent', + color: isSelected ? '#fff' : '#b0b0b0', + fontWeight: isSelected ? 500 : 400, + fontSize: 16, + position: 'relative', + }} + > + {logo} + + {chain === MobulaChainNames.All ? 'All chains' : chain} + + {isSelected && ( +
+ selected-icon +
+ )} +
+ ); + })} +
+
+ + ); +} diff --git a/src/apps/gas-tank/components/Search/ChainSelect.tsx b/src/apps/gas-tank/components/Search/ChainSelect.tsx new file mode 100644 index 00000000..55b35550 --- /dev/null +++ b/src/apps/gas-tank/components/Search/ChainSelect.tsx @@ -0,0 +1,13 @@ +import GlobeIcon from '../../assets/globe-icon.svg'; + +export default function ChainSelectButton() { + return ( + + ); +} diff --git a/src/apps/gas-tank/components/Search/PortfolioTokenList.tsx b/src/apps/gas-tank/components/Search/PortfolioTokenList.tsx new file mode 100644 index 00000000..a0b3b0a9 --- /dev/null +++ b/src/apps/gas-tank/components/Search/PortfolioTokenList.tsx @@ -0,0 +1,281 @@ +import { TailSpin } from 'react-loader-spinner'; + +// types +import { PortfolioData } from '../../../../types/api'; + +// utils +import { convertPortfolioAPIResponseToToken } from '../../../../services/pillarXApiWalletPortfolio'; +import { + chainNameToChainIdTokensData, + Token, +} from '../../../../services/tokensData'; +import { getLogoForChainId } from '../../../../utils/blockchain'; +import { + formatExponentialSmallNumber, + limitDigitsNumber, +} from '../../../../utils/number'; + +// constants +import { STABLE_CURRENCIES } from '../../constants/tokens'; + +// components +import RandomAvatar from '../../../pillarx-app/components/RandomAvatar/RandomAvatar'; + +type SortKey = 'symbol' | 'price' | 'balance' | 'pnl'; +type SortOrder = 'asc' | 'desc'; + +export interface PortfolioTokenListProps { + walletPortfolioData: PortfolioData | undefined; + handleTokenSelect: (item: Token) => void; + isLoading?: boolean; + isError?: boolean; + searchText?: string; + sortKey?: SortKey | null; + sortOrder?: SortOrder; +} + +const PortfolioTokenList = (props: PortfolioTokenListProps) => { + const { + walletPortfolioData, + handleTokenSelect, + isLoading, + isError, + searchText, + sortKey, + sortOrder = 'desc', + } = props; + + const isStableCurrency = (token: Token) => { + const chainId = chainNameToChainIdTokensData(token.blockchain); + return STABLE_CURRENCIES.some( + (stable) => + stable.chainId === chainId && + stable.address.toLowerCase() === token.contract.toLowerCase() + ); + }; + + // Filter out stable currencies and apply search filter + const getFilteredPortfolioTokens = () => { + if (!walletPortfolioData?.assets) return []; + + let tokens = convertPortfolioAPIResponseToToken(walletPortfolioData) + .filter((token) => !isStableCurrency(token)); + + // Apply search filter if searchText is provided + if (searchText && searchText.trim()) { + const searchLower = searchText.toLowerCase(); + tokens = tokens.filter( + (token: Token) => + token.symbol.toLowerCase().includes(searchLower) || + token.name.toLowerCase().includes(searchLower) || + token.contract.toLowerCase().includes(searchLower) + ); + } + + // Apply sorting + if (sortKey) { + tokens.sort((a: Token, b: Token) => { + let valueA: number | string; + let valueB: number | string; + + switch (sortKey) { + case 'symbol': + valueA = a.symbol.toLowerCase(); + valueB = b.symbol.toLowerCase(); + break; + case 'price': + valueA = a.price || 0; + valueB = b.price || 0; + break; + case 'balance': + valueA = (a.price || 0) * (a.balance || 0); + valueB = (b.price || 0) * (b.balance || 0); + break; + case 'pnl': + valueA = a.price_change_24h || 0; + valueB = b.price_change_24h || 0; + break; + default: + return 0; + } + + if (typeof valueA === 'string' && typeof valueB === 'string') { + return sortOrder === 'asc' + ? valueA.localeCompare(valueB) + : valueB.localeCompare(valueA); + } else { + const numA = Number(valueA); + const numB = Number(valueB); + return sortOrder === 'asc' ? numA - numB : numB - numA; + } + }); + } else { + // Default sort by highest USD balance + tokens.sort((a: Token, b: Token) => { + const balanceUSDA = (a.price || 0) * (a.balance || 0); + const balanceUSDB = (b.price || 0) * (b.balance || 0); + return balanceUSDB - balanceUSDA; + }); + } + + return tokens; + }; + + const portfolioTokens = getFilteredPortfolioTokens(); + + // Loading state + if (isLoading) { + return ( + + +
+ +

Loading your portfolio...

+
+ + + ); + } + + // Error state + if (isError) { + return ( + + +
+

⚠️ Failed to load portfolio

+

+ Unable to fetch your wallet data. Please try again later. +

+
+ + + ); + } + + // No data state + if (!walletPortfolioData) { + return ( + + +
+

🔍 No portfolio data

+

+ Connect your wallet to see your holdings +

+
+ + + ); + } + + // Empty portfolio state (either no tokens or no search results) + if (portfolioTokens.length === 0) { + return ( + + +
+

+ {searchText && searchText.trim() + ? '🔍 No matching tokens found' + : '💰 Portfolio is empty'} +

+

+ {searchText && searchText.trim() + ? `No tokens match '${searchText}' in your portfolio` + : // eslint-disable-next-line quotes + "You don't have any tokens in your portfolio yet"} +

+
+ + + ); + } + + return ( + <> + {portfolioTokens.map((token) => { + const balanceUSD = + token.price && token.balance ? token.price * token.balance : 0; + + return ( + { + handleTokenSelect(token); + }} + > + +
+
+ {token.logo ? ( + token logo + ) : ( +
+ + {token.symbol?.slice(0, 2) || token.name?.slice(0, 2)} + +
+ )} + chain logo +
+
+

+ {token.symbol} +

+

+ $ + {token.price + ? formatExponentialSmallNumber( + limitDigitsNumber(token.price) + ) + : '0.00'} +

+
+
+ + + +
+

+ ${Math.floor(balanceUSD * 100) / 100} +

+

+ {Math.floor((token.balance || 0) * 100000) / 100000} +

+
+ + + +
+

= 0 ? 'text-[#4ADE80]' : 'text-[#F87171]' + }`}> + ${(Math.abs(balanceUSD * (token.price_change_24h || 0) / 100)).toFixed(2)} +

+

= 0 ? 'text-[#4ADE80]' : 'text-[#F87171]' + }`}> + {(token.price_change_24h || 0).toFixed(2)}% +

+
+ + + ); + })} + + ); +}; + +export default PortfolioTokenList; diff --git a/src/apps/gas-tank/components/Search/Search.tsx b/src/apps/gas-tank/components/Search/Search.tsx new file mode 100644 index 00000000..7fe38a0e --- /dev/null +++ b/src/apps/gas-tank/components/Search/Search.tsx @@ -0,0 +1,324 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +import React, { + Dispatch, + SetStateAction, + useEffect, + useRef, + useState, +} from 'react'; +import { TailSpin } from 'react-loader-spinner'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { isAddress } from 'viem'; +import { + Token, + chainNameToChainIdTokensData, +} from '../../../../services/tokensData'; +import { PortfolioData } from '../../../../types/api'; +import { + formatExponentialSmallNumber, + limitDigitsNumber, +} from '../../../../utils/number'; +import SearchIcon from '../../assets/seach-icon.svg'; +import { useTokenSearch } from '../../hooks/useTokenSearch'; +import { SearchType, SelectedToken } from '../../types/tokens'; +import { MobulaChainNames } from '../../utils/constants'; +import { Asset, parseSearchData } from '../../utils/parseSearchData'; +import Close from '../Misc/Close'; +import Esc from '../Misc/Esc'; +import Refresh from '../Misc/Refresh'; +import PortfolioTokenList from './PortfolioTokenList'; + +interface SearchProps { + setSearching: Dispatch>; + isBuy: boolean; + setBuyToken: Dispatch>; + setSellToken: Dispatch>; + chains: MobulaChainNames; + setChains: Dispatch>; + walletPortfolioData?: PortfolioData; + walletPortfolioLoading?: boolean; + walletPortfolioFetching?: boolean; + walletPortfolioError?: boolean; + refetchWalletPortfolio?: () => void; +} + + +type SortKey = 'symbol' | 'price' | 'balance' | 'pnl'; +type SortOrder = 'asc' | 'desc'; + +export default function Search({ + setSearching, + isBuy, + setBuyToken, + setSellToken, + chains, + walletPortfolioData, + walletPortfolioLoading, + walletPortfolioFetching, + walletPortfolioError, + refetchWalletPortfolio, +}: SearchProps) { + const { searchText, setSearchText, searchData, isFetching } = useTokenSearch({ + isBuy, + chains, + }); + const [searchType, setSearchType] = useState(); + const [sortKey, setSortKey] = useState(null); + const [sortOrder, setSortOrder] = useState('desc'); + + const inputRef = useRef(null); + const searchModalRef = useRef(null); + + const useQuery = () => { + const { search } = useLocation(); + return new URLSearchParams(search); + }; + + const query = useQuery(); + + const navigate = useNavigate(); + const location = useLocation(); + + const removeQueryParams = () => { + navigate(location.pathname, { replace: true }); + }; + + useEffect(() => { + inputRef.current?.focus(); + const tokenAddress = query.get('asset'); + + // Only read asset parameter when in buy mode to prevent token address + // from token-atlas showing in sell search + if (isAddress(tokenAddress || '')) { + setSearchText(tokenAddress!); + } + + setSearchType(SearchType.MyHoldings); + }, [query, setSearchText]); + + const handleClose = () => { + setSearchText(''); + // It resets search type to MyHoldings if on sell screen + setSearchType(SearchType.MyHoldings); + setSearching(false); + removeQueryParams(); + }; + + // Click outside to close functionality + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + searchModalRef.current && + !searchModalRef.current.contains(event.target as Node) + ) { + handleClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ESC key to close functionality + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortOrder('desc'); + } + }; + + const getSortIcon = (key: SortKey) => { + if (sortKey !== key) return '↕'; + return sortOrder === 'asc' ? '↑' : '↓'; + }; + + const handleTokenSelect = (item: Asset | Token) => { + if (isBuy) { + // Asset type + if ('chain' in item) { + setBuyToken({ + name: item.name, + symbol: item.symbol, + logo: item.logo ?? '', + usdValue: formatExponentialSmallNumber( + limitDigitsNumber(item.price || 0) + ), + dailyPriceChange: -0.02, + chainId: chainNameToChainIdTokensData(item.chain), + decimals: item.decimals, + address: item.contract, + }); + } else { + // Token type + setBuyToken({ + name: item.name, + symbol: item.symbol, + logo: item.logo ?? '', + usdValue: formatExponentialSmallNumber( + limitDigitsNumber(item.price || 0) + ), + dailyPriceChange: -0.02, + chainId: chainNameToChainIdTokensData(item.blockchain), + decimals: item.decimals, + address: item.contract, + }); + } + } else { + const sellTokenData = { + name: item.name, + symbol: item.symbol, + logo: item.logo ?? '', + usdValue: formatExponentialSmallNumber( + limitDigitsNumber(item.price || 0) + ), + dailyPriceChange: -0.02, + decimals: item.decimals, + address: item.contract, + }; + + if ('chain' in item) { + setSellToken({ + ...sellTokenData, + chainId: chainNameToChainIdTokensData(item.chain), + }); + } else { + setSellToken({ + ...sellTokenData, + chainId: chainNameToChainIdTokensData(item.blockchain), + }); + } + } + setSearchText(''); + // This keeps MyHoldings filter active when on sell screen + if (!isBuy) { + setSearchType(SearchType.MyHoldings); + } + setSearching(false); + removeQueryParams(); + }; + + return ( +
+
+ {/* Fixed Header Section */} +
+
+
+ + search-icon + + { + setSearchText(e.target.value); + }} + /> + {(searchText.length > 0 && isFetching) ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+ +
+
+ +
+
+
+ + {/* MyHoldings Header */} +
+
+

+ 💰My Holdings +

+
+
+
+ + {/* Scrollable Content Section */} +
+
+ + + + + + + + + + + +
handleSort('symbol')} + > + Token/Price {getSortIcon('symbol')} + handleSort('balance')} + > + Balance {getSortIcon('balance')} + handleSort('pnl')} + > + Unrealized PnL/% {getSortIcon('pnl')} +
+
+
+
+
+ ); +} diff --git a/src/apps/gas-tank/components/Search/Sort.tsx b/src/apps/gas-tank/components/Search/Sort.tsx new file mode 100644 index 00000000..49726716 --- /dev/null +++ b/src/apps/gas-tank/components/Search/Sort.tsx @@ -0,0 +1,29 @@ +import { SortType } from '../../types/tokens'; + +export interface SortProps { + sortType?: SortType; +} + +export default function Sort(props: SortProps) { + const { sortType } = props; + return ( + + + + + ); +} diff --git a/src/apps/gas-tank/components/Search/TokenList.tsx b/src/apps/gas-tank/components/Search/TokenList.tsx new file mode 100644 index 00000000..5e73882c --- /dev/null +++ b/src/apps/gas-tank/components/Search/TokenList.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { Asset } from '../../utils/parseSearchData'; +import RandomAvatar from '../../../pillarx-app/components/RandomAvatar/RandomAvatar'; +import { getLogoForChainId } from '../../../../utils/blockchain'; +import { chainNameToChainIdTokensData } from '../../../../services/tokensData'; +import { SearchType, SortType } from '../../types/tokens'; +import { formatBigNumber } from '../../utils/number'; +import TokenPrice from '../Price/TokenPrice'; +import TokenPriceChange from '../Price/TokenPriceChange'; + +export interface TokenListProps { + assets: Asset[]; + handleTokenSelect: (item: Asset) => void; + searchType?: SearchType; +} + +export default function TokenList(props: TokenListProps) { + const { assets, handleTokenSelect, searchType } = props; + + const [sort, setSort] = useState<{ + mCap?: SortType; + volume?: SortType; + price?: SortType; + priceChange24h?: SortType; + }>({}); + + const handleSortChange = ( + key: 'mCap' | 'volume' | 'price' | 'priceChange24h' + ) => { + const sortType = + // eslint-disable-next-line no-nested-ternary + sort[key] === SortType.Down + ? SortType.Up + : sort[key] === SortType.Up + ? SortType.Down + : SortType.Up; + + assets.sort((a, b) => { + if (sortType === SortType.Up) { + return (b[key] || 0) - (a[key] || 0); + } + return (a[key] || 0) - (b[key] || 0); + }); + + setSort({ + mCap: undefined, + price: undefined, + priceChange24h: undefined, + volume: undefined, + [key]: sortType, + }); + }; + + if (assets) { + return ( + <> + {assets.map((item) => { + return ( + + ); + })} + + ); + } + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; +} diff --git a/src/apps/gas-tank/components/Search/tests/PortfolioTokenList.test.tsx b/src/apps/gas-tank/components/Search/tests/PortfolioTokenList.test.tsx new file mode 100644 index 00000000..02c86cb3 --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/PortfolioTokenList.test.tsx @@ -0,0 +1,346 @@ +/* eslint-disable quotes */ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen } from '@testing-library/react'; +import renderer from 'react-test-renderer'; +import { vi } from 'vitest'; + +// types +import { PortfolioData } from '../../../../../types/api'; + +// components +import PortfolioTokenList from '../PortfolioTokenList'; + +const mockHandleTokenSelect = vi.fn(); + +const mockPortfolioData: PortfolioData = { + assets: [ + { + asset: { + id: 1, + name: 'Test Token', + symbol: 'TEST', + logo: 'https://example.com/logo.png', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 1.0, + balanceRaw: '1000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0.05, + estimated_balance: 1.5, + price: 1.5, + token_balance: 1.0, + allocation: 0.3, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + { + asset: { + id: 2, + name: 'Another Token', + symbol: 'ANOTHER', + logo: '', + decimals: ['18'], + contracts: ['0x0987654321098765432109876543210987654321'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x0987654321098765432109876543210987654321', + balance: 2.0, + balanceRaw: '2000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: -0.02, + estimated_balance: 4.0, + price: 2.0, + token_balance: 2.0, + allocation: 0.7, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 5.5, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 2, +}; + +const defaultProps = { + walletPortfolioData: mockPortfolioData, + handleTokenSelect: mockHandleTokenSelect, + isLoading: false, + isError: false, + searchText: '', +}; + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly and matches snapshot', () => { + const tree = renderer + .create() + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders loading state', () => { + render(); + + expect(screen.getByText('Loading your portfolio...')).toBeInTheDocument(); + expect(screen.queryByText('Token/Price')).not.toBeInTheDocument(); + }); + + it('renders error state', () => { + render(); + + expect(screen.getByText('⚠️ Failed to load portfolio')).toBeInTheDocument(); + expect( + screen.getByText( + 'Unable to fetch your wallet data. Please try again later.' + ) + ).toBeInTheDocument(); + }); + + it('renders no data state when walletPortfolioData is null', () => { + render( + + ); + + expect(screen.getByText('🔍 No portfolio data')).toBeInTheDocument(); + expect( + screen.getByText('Connect your wallet to see your holdings') + ).toBeInTheDocument(); + }); + + it('renders empty portfolio state', () => { + const emptyPortfolioData: PortfolioData = { + assets: [], + total_wallet_balance: 0, + wallets: [], + balances_length: 0, + }; + + render( + + ); + + expect(screen.getByText('💰 Portfolio is empty')).toBeInTheDocument(); + expect( + screen.getByText("You don't have any tokens in your portfolio yet") + ).toBeInTheDocument(); + }); + + it('renders no matching tokens when search has no results', () => { + render(); + + expect(screen.getByText('🔍 No matching tokens found')).toBeInTheDocument(); + expect( + screen.getByText("No tokens match 'nonexistent' in your portfolio") + ).toBeInTheDocument(); + }); + + it('renders portfolio tokens with correct data', () => { + render(); + + expect(screen.getByText('Token/Price')).toBeInTheDocument(); + expect(screen.getByText('Balance')).toBeInTheDocument(); + expect(screen.getByText('TEST')).toBeInTheDocument(); + expect(screen.getByText('Test Token')).toBeInTheDocument(); + expect(screen.getAllByText('$1.5')).toHaveLength(2); // Price and balance + expect(screen.getByText('ANOTHER')).toBeInTheDocument(); + expect(screen.getByText('Another Token')).toBeInTheDocument(); + expect(screen.getAllByText('$2')).toHaveLength(1); // Only price for ANOTHER + expect(screen.getByText('$4')).toBeInTheDocument(); // Balance for ANOTHER + }); + + it('filters tokens by search text', () => { + render(); + + expect(screen.getByText('TEST')).toBeInTheDocument(); + expect(screen.getByText('Test Token')).toBeInTheDocument(); + expect(screen.queryByText('ANOTHER')).not.toBeInTheDocument(); + expect(screen.queryByText('Another Token')).not.toBeInTheDocument(); + }); + + it('filters tokens by symbol case insensitively', () => { + render(); + + expect(screen.getByText('ANOTHER')).toBeInTheDocument(); + expect(screen.getByText('Another Token')).toBeInTheDocument(); + expect(screen.queryByText('TEST')).not.toBeInTheDocument(); + expect(screen.queryByText('Test Token')).not.toBeInTheDocument(); + }); + + it('filters tokens by contract address', () => { + render(); + + expect(screen.getByText('TEST')).toBeInTheDocument(); + expect(screen.getByText('Test Token')).toBeInTheDocument(); + expect(screen.queryByText('ANOTHER')).not.toBeInTheDocument(); + }); + + it('calls handleTokenSelect when token is clicked', () => { + render(); + + const tokenButton = screen.getByText('TEST').closest('button'); + tokenButton?.click(); + + expect(mockHandleTokenSelect).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'TEST', + name: 'Test Token', + contract: '0x1234567890123456789012345678901234567890', + }) + ); + }); + + it('sorts tokens by USD value in descending order', () => { + render(); + + const tokenButtons = screen.getAllByRole('button'); + const firstToken = tokenButtons[0]; + const secondToken = tokenButtons[1]; + + expect(firstToken).toHaveTextContent('ANOTHER'); // Higher USD value (4.0) + expect(secondToken).toHaveTextContent('TEST'); // Lower USD value (1.5) + }); + + it('displays token logos when available', () => { + render(); + + const testTokenImage = screen.getByAltText('token logo'); + expect(testTokenImage).toHaveAttribute( + 'src', + 'https://example.com/logo.png' + ); + }); + + it('displays random avatar when logo is not available', () => { + render(); + + const anotherTokenContainer = screen.getByText('ANOTHER').closest('button'); + const avatarContainer = anotherTokenContainer?.querySelector( + '.w-8.h-8.rounded-full.overflow-hidden' + ); + expect(avatarContainer).toBeInTheDocument(); + }); + + it('displays chain logos for each token', () => { + render(); + + const chainLogos = screen.getAllByAltText('chain logo'); + expect(chainLogos).toHaveLength(2); + }); + + it('handles tokens with zero balance by showing empty portfolio', () => { + const portfolioWithZeroBalance: PortfolioData = { + assets: [ + { + asset: { + id: 3, + name: 'Zero Token', + symbol: 'ZERO', + logo: '', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 0, + balanceRaw: '0', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0, + estimated_balance: 0, + price: 1.0, + token_balance: 0, + allocation: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 1, + }; + + render( + + ); + + // Zero balance tokens are filtered out, so we should see empty portfolio message + expect(screen.getByText('💰 Portfolio is empty')).toBeInTheDocument(); + expect( + screen.getByText("You don't have any tokens in your portfolio yet") + ).toBeInTheDocument(); + }); + + it('handles tokens with null price', () => { + const portfolioWithNullPrice: PortfolioData = { + assets: [ + { + asset: { + id: 4, + name: 'No Price Token', + symbol: 'NOPRICE', + logo: '', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 1.0, + balanceRaw: '1000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0, + estimated_balance: 0, + price: 0, + token_balance: 1.0, + allocation: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 0, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 1, + }; + + render( + + ); + + expect(screen.getByText('NOPRICE')).toBeInTheDocument(); + expect(screen.getByText('$0.00')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/gas-tank/components/Search/tests/Search.test.tsx b/src/apps/gas-tank/components/Search/tests/Search.test.tsx new file mode 100644 index 00000000..0899136c --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/Search.test.tsx @@ -0,0 +1,377 @@ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import renderer from 'react-test-renderer'; +import { vi } from 'vitest'; + +// types +import { PortfolioData } from '../../../../../types/api'; + +// hooks +import * as useTokenSearch from '../../../hooks/useTokenSearch'; + +// utils +import { MobulaChainNames } from '../../../utils/constants'; + +// components +import Search from '../Search'; + +// Mock dependencies +vi.mock('../../../hooks/useTokenSearch', () => ({ + useTokenSearch: vi.fn(), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: () => ({ + search: '?asset=0x1234567890123456789012345678901234567890', + pathname: '/', + }), + useNavigate: () => vi.fn(), + }; +}); + +const mockSetSearching = vi.fn(); +const mockSetBuyToken = vi.fn(); +const mockSetSellToken = vi.fn(); +const mockSetChains = vi.fn(); + +const mockPortfolioData: PortfolioData = { + assets: [ + { + asset: { + id: 1, + name: 'Test Token', + symbol: 'TEST', + logo: 'https://example.com/logo.png', + decimals: ['18'], + contracts: ['0x1234567890123456789012345678901234567890'], + blockchains: ['ethereum'], + }, + contracts_balances: [ + { + address: '0x1234567890123456789012345678901234567890', + balance: 1.0, + balanceRaw: '1000000000000000000', + chainId: 'eip155:1', + decimals: 18, + }, + ], + cross_chain_balances: {}, + price_change_24h: 0.05, + estimated_balance: 1.5, + price: 1.5, + token_balance: 1.0, + allocation: 1.0, + wallets: ['0x1234567890123456789012345678901234567890'], + }, + ], + total_wallet_balance: 1.5, + wallets: ['0x1234567890123456789012345678901234567890'], + balances_length: 1, +}; + +const defaultProps = { + setSearching: mockSetSearching, + isBuy: true, + setBuyToken: mockSetBuyToken, + setSellToken: mockSetSellToken, + chains: MobulaChainNames.Ethereum, + setChains: mockSetChains, + walletPortfolioData: mockPortfolioData, + walletPortfolioLoading: false, + walletPortfolioError: false, +}; + +const mockUseTokenSearch = { + searchText: '', + setSearchText: vi.fn(), + searchData: null, + isFetching: false, +}; + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useTokenSearch.useTokenSearch as any).mockReturnValue(mockUseTokenSearch); + }); + + it('renders correctly and matches snapshot', () => { + const tree = renderer + .create( + + + + ) + .toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders main search interface elements', () => { + render( + + + + ); + + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + expect( + screen.getByTestId('pulse-search-filter-buttons') + ).toBeInTheDocument(); + }); + + it('renders buy mode filter buttons', () => { + render( + + + + ); + + expect(screen.getByText('🔥 Trending')).toBeInTheDocument(); + expect(screen.getByText('🌱 Fresh')).toBeInTheDocument(); + expect(screen.getByText('🚀 Top Gainers')).toBeInTheDocument(); + expect(screen.getByText('💰My Holdings')).toBeInTheDocument(); + }); + + it('renders sell mode with only My Holdings', () => { + render( + + + + ); + + expect(screen.getByText('My Holdings')).toBeInTheDocument(); + expect(screen.queryByText('🔥 Trending')).not.toBeInTheDocument(); + expect(screen.queryByText('🌱 Fresh')).not.toBeInTheDocument(); + expect(screen.queryByText('🚀 Top Gainers')).not.toBeInTheDocument(); + }); + + it('handles search input changes', () => { + const mockSetSearchText = vi.fn(); + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + setSearchText: mockSetSearchText, + }); + + render( + + + + ); + + const input = screen.getByTestId('pulse-search-input'); + fireEvent.change(input, { target: { value: 'test search' } }); + + expect(mockSetSearchText).toHaveBeenCalledWith('test search'); + }); + + it('handles filter button clicks in buy mode', () => { + render( + + + + ); + + const trendingButton = screen.getByText('🔥 Trending'); + fireEvent.click(trendingButton); + + // Should trigger search type change + expect(screen.getByText('🔥 Trending')).toBeInTheDocument(); + }); + + it('shows loading spinner when fetching', () => { + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + isFetching: true, + searchText: 'test', + }); + + render( + + + + ); + + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + }); + + it('shows close button when not fetching', () => { + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + isFetching: false, + searchText: 'test', + }); + + render( + + + + ); + + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + }); + + it('displays My Holdings text when in sell mode', () => { + render( + + + + ); + + expect(screen.getByText('My Holdings')).toBeInTheDocument(); + }); + + it('handles token selection for buy mode', () => { + render( + + + + ); + + // Test that the component renders without errors + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + + // Test that buy mode shows all filter buttons + expect(screen.getByText('🔥 Trending')).toBeInTheDocument(); + expect(screen.getByText('🌱 Fresh')).toBeInTheDocument(); + expect(screen.getByText('🚀 Top Gainers')).toBeInTheDocument(); + expect(screen.getByText('💰My Holdings')).toBeInTheDocument(); + }); + + it('handles token selection for sell mode', () => { + render( + + + + ); + + // Simulate token selection + const tokenButton = screen.getByText('TEST').closest('button'); + if (tokenButton) { + fireEvent.click(tokenButton); + } + + expect(mockSetSellToken).toHaveBeenCalled(); + }); + + it('shows search placeholder when no search text and no parsed assets', () => { + (useTokenSearch.useTokenSearch as any).mockReturnValue({ + ...mockUseTokenSearch, + searchText: '', + }); + + render( + + + + ); + + expect( + screen.getByText('Search by token or paste address...') + ).toBeInTheDocument(); + }); + + it('handles chain overlay toggle', () => { + render( + + + + ); + + const chainButton = screen.getByRole('button', { name: /save/i }); + fireEvent.click(chainButton); + + // Chain overlay should be triggered + expect(chainButton).toBeInTheDocument(); + }); + + it('handles refresh button click', () => { + render( + + + + ); + + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + fireEvent.click(refreshButton); + + expect(refreshButton).toBeInTheDocument(); + }); + + it('handles portfolio loading state', () => { + render( + + + + ); + + // Should still render the main search interface + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + }); + + it('handles portfolio error state', () => { + render( + + + + ); + + // Should still render the main search interface + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + }); + + it('handles empty portfolio data', () => { + render( + + + + ); + + // Should still render the main search interface + expect(screen.getByTestId('pulse-search-view')).toBeInTheDocument(); + expect(screen.getByTestId('pulse-search-modal')).toBeInTheDocument(); + }); + + it('handles close button click', () => { + render( + + + + ); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + expect(mockSetSearching).toHaveBeenCalledWith(false); + }); + + it('auto-focuses search input on mount', () => { + render( + + + + ); + + const input = screen.getByTestId('pulse-search-input'); + expect(input).toBeInTheDocument(); + }); + + it('handles URL asset parameter on mount', () => { + render( + + + + ); + + // Should set search text from URL parameter + expect(screen.getByTestId('pulse-search-input')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/gas-tank/components/Search/tests/__snapshots__/PortfolioTokenList.test.tsx.snap b/src/apps/gas-tank/components/Search/tests/__snapshots__/PortfolioTokenList.test.tsx.snap new file mode 100644 index 00000000..4a46a977 --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/__snapshots__/PortfolioTokenList.test.tsx.snap @@ -0,0 +1,230 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders correctly and matches snapshot 1`] = ` +[ +
+
+

+ Token/Price +

+
+
+

+ Balance +

+
+
, + , + , +] +`; diff --git a/src/apps/gas-tank/components/Search/tests/__snapshots__/Search.test.tsx.snap b/src/apps/gas-tank/components/Search/tests/__snapshots__/Search.test.tsx.snap new file mode 100644 index 00000000..599a4767 --- /dev/null +++ b/src/apps/gas-tank/components/Search/tests/__snapshots__/Search.test.tsx.snap @@ -0,0 +1,158 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders correctly and matches snapshot 1`] = ` +
+
+
+
+ + search-icon + + + +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+

+ Search by token or paste address... +

+
+
+
+`; diff --git a/src/apps/gas-tank/components/TopUpModal.tsx b/src/apps/gas-tank/components/TopUpModal.tsx index 5ce01c5d..ef6d81c5 100644 --- a/src/apps/gas-tank/components/TopUpModal.tsx +++ b/src/apps/gas-tank/components/TopUpModal.tsx @@ -11,7 +11,7 @@ import { convertPortfolioAPIResponseToToken, useGetWalletPortfolioQuery, } from '../../../services/pillarXApiWalletPortfolio'; -import { PortfolioToken, chainNameToChainIdTokensData } from '../../../services/tokensData'; +import { PortfolioToken } from '../../../services/tokensData'; // hooks import useGlobalTransactionsBatch from '../../../hooks/useGlobalTransactionsBatch'; @@ -24,12 +24,18 @@ import { setWalletPortfolio } from '../reducer/gasTankSlice'; // utils import { formatTokenAmount } from '../utils/converters'; +import { getLogoForChainId } from '../../../utils/blockchain'; // types import { PortfolioData } from '../../../types/api'; import { logExchangeError, logExchangeEvent } from '../utils/sentry'; import { useTransactionDebugLogger } from '../../../hooks/useTransactionDebugLogger'; +// Search component +import Search from './Search/Search'; +import { SelectedToken } from '../types/tokens'; +import { getChainName, MobulaChainNames } from '../utils/constants'; + interface TopUpModalProps { isOpen: boolean; onClose: () => void; @@ -50,7 +56,8 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const { showSend, setShowBatchSendModal } = useBottomMenuModal(); const { getStepTransactions, getBestOffer } = useOffer(); const dispatch = useAppDispatch(); - const [selectedToken, setSelectedToken] = useState( + const [chains, setChains] = useState(MobulaChainNames.All); + const [selectedToken, setSelectedToken] = useState( null ); const [errorMsg, setErrorMsg] = useState(null); @@ -64,6 +71,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { swapTransactions: any[]; } | null>(null); const [swapAmountUsdPrice, setSwapAmountUsdPrice] = useState(0); + const [showTokenSelection, setShowTokenSelection] = useState(false); const { transactionDebugLog } = useTransactionDebugLogger(); const walletPortfolio = useAppSelector( @@ -75,6 +83,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { data: walletPortfolioData, isSuccess: isWalletPortfolioDataSuccess, error: walletPortfolioDataError, + refetch: refetchWalletPortfolioData, } = useGetWalletPortfolioQuery( { wallet: walletAddress || '', isPnl: false }, { skip: !walletAddress } @@ -87,6 +96,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const tokens = convertPortfolioAPIResponseToToken( walletPortfolioData.result.data ); + console.log(tokens); setPortfolioTokens(tokens); } if (!isWalletPortfolioDataSuccess || walletPortfolioDataError) { @@ -110,7 +120,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { const handleTopUp = async () => { if (!selectedToken || !amount || !walletAddress) return; - if (USDC_ADDRESSES[chainNameToChainIdTokensData(selectedToken.blockchain)] === undefined) { + if (USDC_ADDRESSES[selectedToken.chainId] === undefined) { setErrorMsg('Gas Tank is not supported on the selected token\'s chain.'); return; } @@ -122,8 +132,8 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { // Validate amount const n = Number(amount); - if (!Number.isFinite(n) || n <= 0 || n > (selectedToken.balance ?? 0)) { - setErrorMsg('Enter a valid amount within your balance.'); + if (!Number.isFinite(n) || n <= 0) { + setErrorMsg('Enter a valid amount.'); return; } // Reset error if valid @@ -133,7 +143,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { try { // Check if token is USDC - const isUSDC = selectedToken.contract.toLowerCase() === USDC_ADDRESSES[chainNameToChainIdTokensData(selectedToken.blockchain)].toLowerCase(); + const isUSDC = selectedToken.address.toLowerCase() === USDC_ADDRESSES[selectedToken.chainId].toLowerCase(); let receiveSwapAmount = amount; @@ -141,9 +151,9 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { // Need to swap to USDC first try { const bestOffer = await getBestOffer({ - fromTokenAddress: selectedToken.contract, + fromTokenAddress: selectedToken.address, fromAmount: Number(amount), - fromChainId: chainNameToChainIdTokensData(selectedToken.blockchain), + fromChainId: selectedToken.chainId, fromTokenDecimals: selectedToken.decimals, slippage: 0.03, }); @@ -186,7 +196,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { // Call the paymaster API for USDC deposits const response = await fetch( - `${paymasterUrl}/getTransactionForDeposit?chainId=${chainNameToChainIdTokensData(selectedToken.blockchain)}&amount=${receiveSwapAmount}`, + `${paymasterUrl}/getTransactionForDeposit?chainId=${selectedToken.chainId}&amount=${receiveSwapAmount}`, { method: 'GET', headers: { @@ -231,7 +241,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { to: tx.to, value: integerValue, data: tx.data, - chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + chainId: selectedToken.chainId, }); }); } else { @@ -257,7 +267,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { to: transactionData.result.to, value: integerValue, data: transactionData.result.data, - chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + chainId: selectedToken.chainId, }); } @@ -283,19 +293,16 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { } let tokenUsdPrice = 0; if (selectedToken) { - tokenUsdPrice = Number(value) * (selectedToken.price ?? 0); + tokenUsdPrice = Number(value) * (parseFloat(selectedToken.usdValue) ?? 0); } setSwapAmountUsdPrice(tokenUsdPrice); }; - const getMaxAmount = () => { - if (!selectedToken) return '0'; - return formatTokenAmount(selectedToken.balance); - }; - const handleMaxClick = () => { setErrorMsg(null); - setAmount(getMaxAmount() || ''); + // Since we don't have balance info in SelectedToken, we'll just clear the amount + // In a real implementation, you'd need to fetch balance for the selected token + setAmount(''); }; const handleConfirmSwap = async () => { @@ -334,13 +341,13 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { to: tx.to, value: integerValue, data: tx.data, - chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + chainId: selectedToken.chainId, }); }); // Continue with the paymaster API call for USDC deposits const response = await fetch( - `${paymasterUrl}/getTransactionForDeposit?chainId=${chainNameToChainIdTokensData(selectedToken.blockchain)}&amount=${swapDetails.receiveAmount}`, + `${paymasterUrl}/getTransactionForDeposit?chainId=${selectedToken.chainId}&amount=${swapDetails.receiveAmount}`, { method: 'GET', headers: { @@ -385,7 +392,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { to: tx.to, value: integerValue, data: tx.data, - chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + chainId: selectedToken.chainId, }); }); } else { @@ -411,7 +418,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { to: transactionData.result.to, value: integerValue, data: transactionData.result.data, - chainId: chainNameToChainIdTokensData(selectedToken.blockchain), + chainId: selectedToken.chainId, }); } @@ -436,6 +443,27 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { if (!isOpen) return null; + // Token Selection Modal using Search component + if (showTokenSelection) { + return ( + + {}} // Not used in this context + chains={chains} // Show all supported chains + setChains={() => {}} // Not allowing chain changes in gas tank + walletPortfolioData={walletPortfolioData?.result?.data} + walletPortfolioLoading={!isWalletPortfolioDataSuccess && !walletPortfolioDataError} + walletPortfolioFetching={false} + walletPortfolioError={!!walletPortfolioDataError} + refetchWalletPortfolio={refetchWalletPortfolioData} + /> + + ); + } + // Swap Confirmation Modal if (showSwapConfirmation && swapDetails && selectedToken) { return ( @@ -460,7 +488,7 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { On: - {selectedToken.blockchain} + {getChainName(selectedToken.chainId)} @@ -497,121 +525,110 @@ const TopUpModal = ({ isOpen, onClose, onSuccess }: TopUpModalProps) => { return ( - -
- Top up Gas Tank + + + Top up -
- - -
- - {(() => { - if (!portfolioTokens) { - return ( - - - Loading wallet tokens... - - ); - } - if (walletPortfolioDataError) { - return ( - Failed to load wallet tokens - ); - } - return ( - - {portfolioTokens.map((token) => ( - setSelectedToken(token)} - $isSelected={ - selectedToken?.contract === token.contract && - selectedToken?.blockchain === - token.blockchain - } - > - - - - {token.symbol} - {token.name} - {token.blockchain} - - - - - {formatTokenAmount(token.balance)} - - - {(() => { - const usdValue = (token.balance || 0) * (token.price || 0); - return usdValue < 0.01 ? '<$0.01' : `$${usdValue.toFixed(2)}`; - })()} - - - - ))} - - ); - })()} -
- - {selectedToken && ( -
- - + + + + Select Fee Tokens and Input Amount + + + + setShowTokenSelection(true)}> + {selectedToken ? ( + + + + + + + {selectedToken.symbol} + on {getChainName(selectedToken.chainId)} + + + + ) : ( + + Select Token + + + )} + + + + $ handleAmountChange(e.target.value)} + value={selectedToken && amount ? amount : ''} + onChange={(e) => { + handleAmountChange(e.target.value); + }} /> - - MAX - - - {selectedToken && amount && !isNaN(Number(amount)) && Number(amount) > 0 && ( - - {(() => { - const usdValue = Number(amount) * (selectedToken.price || 0); - return usdValue < 0.01 ? '<$0.01' : `$${usdValue.toFixed(2)}`; - })()} - - )} - - Available: {getMaxAmount()} {selectedToken.symbol} - -
+ + + + + + {selectedToken && amount ? (Number(amount)/(parseFloat(selectedToken.usdValue) || 0)).toFixed(2) : '0.00' } {selectedToken?.symbol} + + + + + {errorMsg && ( + + ⚠️ + {errorMsg} + )} - + + Rate + 1 USD ≈ {selectedToken ? (1 / parseFloat(selectedToken.usdValue)).toFixed(4) : '1.08'} {selectedToken?.symbol || 'USDC'} + + + + Price impact + + + 0.00% + + + + Gas fee + + + ≈ $0.05 + + + + {(() => { - if (errorMsg) { - return errorMsg; - } if (isProcessing) { return ( <> - {'Processing...'} + Processing... ); } - if (selectedToken?.symbol?.toUpperCase() === 'USDC') { - return 'Add to Gas Tank'; + if (selectedToken && amount) { + return `Top Up $${amount}`; } - return 'Swap & Add to Gas Tank'; + return 'Top Up'; })()} - -
-
+ + +
); }; @@ -830,25 +847,6 @@ const AmountContainer = styled.div` gap: 12px; `; -const AmountInput = styled.input` - flex: 1; - background: #2a2a2a; - border: 1px solid #444; - border-radius: 8px; - padding: 12px 16px; - color: #ffffff; - font-size: 16px; - - &:focus { - outline: none; - border-color: #7c3aed; - } - - &::placeholder { - color: #6b7280; - } -`; - const UsdPriceDisplay = styled.div` background: #2a2a2a; border: 1px solid #444; @@ -960,12 +958,12 @@ const WarningBox = styled.div` padding: 16px; `; -const WarningText = styled.p` - color: #fbbf24; - font-size: 14px; - margin: 0; - line-height: 1.5; -`; +// const WarningText = styled.p` +// color: #fbbf24; +// font-size: 14px; +// margin: 0; +// line-height: 1.5; +// `; const ButtonContainer = styled.div` display: flex; @@ -1019,4 +1017,290 @@ const ConfirmButton = styled.button` } `; +// New Modal Styles +const NewModalContainer = styled.div` + background: #1a1a1a; + border-radius: 20px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + border: 1px solid #333; +`; + +const NewHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px; + border-bottom: 1px solid #333; +`; + +const NewTitle = styled.h2` + color: #ffffff; + font-size: 24px; + font-weight: 600; + margin: 0; +`; + +const NewContent = styled.div` + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +`; + +const SectionDescription = styled.div` + color: #9ca3af; + font-size: 16px; + margin-bottom: 4px; +`; + +const TokenAmountContainer = styled.div` + background: #000000; + border: 1px solid #444; + border-radius: 12px; + padding: 0; + overflow: hidden; + transition: border-color 0.2s; + + &:hover { + border-color: #7c3aed; + } +`; + +const MainRow = styled.div` + padding: 16px; + display: flex; + justify-content: flex-start; + align-items: center; + position: relative; +`; + +const SelectTokenButton = styled.button` + background: rgba(139, 92, 246, 0.1); + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 50px; + cursor: pointer; + display: flex; + align-items: center; + padding: 8px 16px; + outline: none; + margin-right: 16px; + transition: all 0.2s ease; + + &:hover { + background: rgba(139, 92, 246, 0.2); + border-color: rgba(139, 92, 246, 0.5); + } + + &:focus { + outline: none; + } +`; + +const SelectedTokenDisplay = styled.div` + display: flex; + align-items: center; + gap: 12px; + flex: 1; +`; + +const SelectedTokenLogo = styled.img` + width: 32px; + height: 32px; + border-radius: 50%; +`; + +const TokenLogoContainer = styled.div` + position: relative; + display: inline-block; +`; + +const ChainLogoOverlay = styled.img` + position: absolute; + bottom: 0; + right: 0; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid #000000; +`; + +const SelectedTokenDetails = styled.div` + display: flex; + flex-direction: column; +`; + +const SelectedTokenSymbol = styled.div` + color: #ffffff; + font-weight: 600; + font-size: 16px; +`; + +const SelectedTokenChain = styled.div` + color: #8b5cf6; + font-size: 12px; +`; + +const SelectTokenPlaceholder = styled.div` + color: #9ca3af; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + font-size: 16px; +`; + +const DropdownArrow = styled.span` + color: #9ca3af; + font-size: 12px; +`; + +const AmountInputGroup = styled.div` + display: flex; + align-items: center; + position: absolute; + right: 16px; +`; + +const DollarSymbol = styled.span` + color: #ffffff; + font-size: 48px; + font-weight: 700; + margin-right: -2px; +`; + +const AmountInput = styled.input` + background: transparent; + border: none; + color: #ffffff; + font-size: 48px; + font-weight: 700; + outline: none; + width: 9rem; + text-align: right; + padding: 0; + margin: 0; + margin-left: -2px; + + &::placeholder { + color: #6b7280; + font-weight: 700; + text-align: right; + } + + &:focus { + outline: none; + } +`; + +const TokenAmountRow = styled.div` + padding: 16px; + display: flex; + justify-content: flex-end; + align-items: center; +`; + +const TokenAmountDisplay = styled.div` + color: #9ca3af; + font-size: 14px; +`; + + +const WarningContainer = styled.div` + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 8px; + padding: 12px; + display: flex; + align-items: center; + gap: 8px; +`; + +const WarningIcon = styled.span` + font-size: 16px; +`; + +const WarningText = styled.div` + color: #fbbf24; + font-size: 14px; +`; + +const DetailsSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const DetailRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const DetailLabel = styled.div` + color: #9ca3af; + font-size: 14px; + display: flex; + align-items: center; + gap: 4px; +`; + +const DetailValue = styled.div` + color: #ffffff; + font-size: 14px; + font-weight: 500; +`; + +const InfoIcon = styled.span` + color: #6b7280; + font-size: 12px; +`; + +const NewTopUpButton = styled.button` + width: 100%; + background: linear-gradient(135deg, #7c3aed, #a855f7); + color: white; + border: none; + border-radius: 12px; + padding: 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(124, 58, 237, 0.4); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + + +const SearchOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + backdrop-filter: blur(4px); + z-index: 3000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +`; + export default TopUpModal; diff --git a/src/apps/gas-tank/components/UniversalGasTank.styles.ts b/src/apps/gas-tank/components/UniversalGasTank.styles.ts new file mode 100644 index 00000000..6e9695f6 --- /dev/null +++ b/src/apps/gas-tank/components/UniversalGasTank.styles.ts @@ -0,0 +1,365 @@ +import styled, { keyframes, css } from 'styled-components'; +import { darken } from 'polished'; +import { colors, typography } from './shared.styles'; + +const shimmer = keyframes` + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +`; + +const skeletonAnimation = css` + background: linear-gradient( + 90deg, + ${colors.background} 0%, + rgba(139, 92, 246, 0.1) 50%, + ${colors.background} 100% + ); + background-size: 1000px 100%; + animation: ${shimmer} 2s infinite linear; + border-radius: 4px; +`; + + +export const S = { + MainContainer: styled.div` + background: #1A1B1E; + border-radius: 16px; + padding: 24px; + width: 100%; + max-width: 1400px; + color: #FFFFFF; + display: flex; + gap: 32px; + + @media (max-width: 768px) { + flex-direction: column; + max-width: 480px; + gap: 24px; + } + `, + + LeftSection: styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + min-width: 400px; + + @media (max-width: 768px) { + min-width: auto; + } + `, + + RightSection: styled.div` + flex: 1.5; + + @media (max-width: 768px) { + flex: 1; + } + `, + + Header: styled.div` + display: flex; + justify-content: flex-start; + align-items: center; + margin-bottom: 16px; + `, + + TitleSection: styled.div` + display: flex; + align-items: center; + gap: 12px; + `, + + Icon: styled.span` + font-size: 24px; + `, + + IconImage: styled.img` + width: 24px; + height: 24px; + object-fit: contain; + `, + + Title: styled.h2` + font-size: 20px; + font-weight: 600; + margin: 0; + color: #FFFFFF; + `, + + RefreshButton: styled.button` + background: rgba(139, 92, 246, 0.1); + color: ${colors.text.accent}; + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + + &:hover { + background: rgba(139, 92, 246, 0.2); + border-color: rgba(139, 92, 246, 0.5); + transform: rotate(90deg); + } + `, + + BalanceSection: styled.div` + margin-bottom: 24px; + display: flex; + align-items: baseline; + gap: 8px; + `, + + BalanceAmount: styled.div` + font-size: 48px; + font-weight: 600; + color: #FFFFFF; + `, + + LoadingBalance: styled.div` + ${skeletonAnimation} + display: flex; + align-items: center; + gap: 12px; + color: #9ca3af; + padding: 20px; + justify-content: center; + height: 48px; + width: 180px; + `, + + LoadingContainer: styled.div` + display: flex; + gap: 32px; + width: 100%; + + @media (max-width: 768px) { + flex-direction: column; + gap: 24px; + } + `, + + LoadingLeftSection: styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + min-width: 400px; + + @media (max-width: 768px) { + min-width: auto; + } + `, + + LoadingRightSection: styled.div` + flex: 1.5; + display: flex; + flex-direction: column; + gap: 16px; + + @media (max-width: 768px) { + flex: 1; + } + `, + + LoadingHeader: styled.div` + ${skeletonAnimation} + height: 24px; + width: 200px; + margin-bottom: 16px; + `, + + LoadingBalanceAmount: styled.div` + ${skeletonAnimation} + height: 40px; + width: 150px; + margin-bottom: 8px; + `, + + LoadingNetworkLabel: styled.div` + ${skeletonAnimation} + height: 16px; + width: 120px; + margin-bottom: 24px; + `, + + LoadingButton: styled.div` + ${skeletonAnimation} + height: 44px; + width: 100%; + border-radius: 12px; + margin-bottom: 16px; + `, + + LoadingDescription: styled.div` + ${skeletonAnimation} + height: 16px; + width: 100%; + margin-bottom: 16px; + `, + + LoadingSpendInfo: styled.div` + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0; + `, + + LoadingSpendLabel: styled.div` + ${skeletonAnimation} + height: 16px; + width: 80px; + `, + + LoadingSpendAmount: styled.div` + ${skeletonAnimation} + height: 16px; + width: 60px; + `, + + LoadingDetailedDescription: styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; + `, + + LoadingDetailedLine: styled.div` + ${skeletonAnimation} + height: 14px; + width: 100%; + + &:last-child { + width: 70%; + } + `, + + LoadingHistoryHeader: styled.div` + ${skeletonAnimation} + height: 20px; + width: 180px; + margin-bottom: 16px; + `, + + LoadingTableHeader: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid ${colors.border}; + margin-bottom: 12px; + `, + + LoadingTableHeaderCell: styled.div` + ${skeletonAnimation} + height: 14px; + width: 80%; + `, + + LoadingTableRow: styled.div` + display: grid; + grid-template-columns: 0.5fr 1.5fr 1fr 1fr 1.5fr; + gap: 16px; + padding: 12px 16px; + margin-bottom: 12px; + `, + + LoadingTableCell: styled.div` + ${skeletonAnimation} + height: 16px; + width: 90%; + + &:first-child { + width: 30px; + } + + &:last-child { + width: 100%; + } + `, + + ErrorBalance: styled.div` + ${typography.body}; + color: ${colors.status.error}; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + `, + + RetryButton: styled.button` + background: ${colors.status.error}; + color: ${colors.text.primary}; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + + &:hover { + background: #dc2626; + } + `, + + NetworkLabel: styled.div` + color: #3B82F6; + font-size: 12px; + `, + + TopUpButton: styled.button` + background: #6D28D9; + color: #FFFFFF; + border: none; + border-radius: 12px; + padding: 12px 24px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + width: fit-content; + min-width: 200px; + + &:hover { + background: ${darken(0.1, '#6D28D9')}; + } + `, + + Description: styled.p` + ${typography.body}; + margin: 0 0 16px 0; + `, + + SpendInfo: styled.div` + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0; + `, + + SpendLabel: styled.span` + color: #22C55E; + font-size: 14px; + `, + + SpendAmount: styled.span` + color: #FFFFFF; + font-size: 14px; + font-weight: 600; + `, + + DetailedDescription: styled.p` + color: #A1A1AA; + font-size: 14px; + line-height: 1.6; + margin: 0; + ` +}; \ No newline at end of file diff --git a/src/apps/gas-tank/components/UniversalGasTank.tsx b/src/apps/gas-tank/components/UniversalGasTank.tsx index 79e69461..41d0dc5b 100644 --- a/src/apps/gas-tank/components/UniversalGasTank.tsx +++ b/src/apps/gas-tank/components/UniversalGasTank.tsx @@ -1,98 +1,163 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { useState } from 'react'; -import styled from 'styled-components'; import { CircularProgress } from '@mui/material'; import { useWalletAddress } from '@etherspot/transaction-kit'; +import { S } from './UniversalGasTank.styles'; // components import TopUpModal from './TopUpModal'; // hooks import useGasTankBalance from '../hooks/useGasTankBalance'; -import { useGasTankHistory } from './GasTankHistory'; // import the hook +import GasTankHistory, { useGasTankHistory } from './GasTankHistory'; + +// assets +import gasTankIcon from '../assets/gas-tank-icon.png'; const UniversalGasTank = () => { const walletAddress = useWalletAddress(); + const [showTopUpModal, setShowTopUpModal] = useState(false); + const { totalBalance, isLoading: isBalanceLoading, error: balanceError, refetch, - } = useGasTankBalance(); + } = useGasTankBalance({ pauseAutoRefresh: showTopUpModal }); // Use the custom hook to get totalSpend from history const { + historyData, totalSpend = 0, loading: isHistoryLoading, error: historyError, refetch: refetchHistory, - } = useGasTankHistory(walletAddress); - - const [showTopUpModal, setShowTopUpModal] = useState(false); + } = useGasTankHistory(walletAddress, { pauseAutoRefresh: showTopUpModal }); const handleTopUp = () => { setShowTopUpModal(true); }; - return ( - -
- - - Universal Gas Tank - - {!isBalanceLoading && !balanceError && ( - - 🔄 - - )} -
- - - {(() => { - if (isBalanceLoading) { - return ( - - - Loading balance... - - ); - } - if (balanceError) { - return ( - - Sorry, we had an issue loading your balance. Try pressing the retry button. - Retry - - ); - } - return ${totalBalance.toFixed(2)}; - })()} - On All Networks - - - Top up - - - Top up your Gas Tank so you pay for network fees on every chain - - - - Total Spend: - - {isHistoryLoading || historyError - ? '$0.00' - : `$${totalSpend.toFixed(2)}`} - - + // Show skeleton loading if either balance or history is loading + if (isBalanceLoading || isHistoryLoading) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {Array.from({ length: 8 }).map((_, index) => ( + + + + + + + + ))} + + + + setShowTopUpModal(false)} + onSuccess={() => { + setShowTopUpModal(false); + refetch(); + refetchHistory(); + }} + /> + + ); + } - - The PillarX Gas Tank is your universal balance for covering transaction - fees across all networks. When you top up your Tank, you're - allocating tokens specifically for paying gas. You can increase your - balance anytime, and the tokens in your Tank can be used to pay network - fees on any supported chain. - + return ( + + + + + + Universal Gas Tank + + + + + {balanceError ? ( + + Error loading balance + Retry + + ) : ( + <> + ${totalBalance.toFixed(2)} + On All Networks + + )} + + + Top up + + + Top up your Gas Tank so you pay for network fees on every chain. + + + + Total Spend + + {historyError ? '$0.00' : `$${totalSpend.toFixed(2)}`} + + + + + The PillarX Gas Tank is your universal balance for covering transaction + fees across all networks. When you top up your Tank, you're + allocating tokens specifically for paying gas. You can increase your + balance anytime, and the tokens in your Tank can be used to pay network + fees on any supported chain. + + + + + + { onSuccess={() => { setShowTopUpModal(false); refetch(); + refetchHistory(); }} /> -
+ ); }; -const Container = styled.div` - background: #1a1a1a; - border-radius: 16px; - padding: 24px; - border: 1px solid #333; -`; - -const Header = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 24px; -`; - -const TitleSection = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; - -const RefreshButton = styled.button` - background: rgba(139, 92, 246, 0.1); - color: #8b5cf6; - border: 1px solid rgba(139, 92, 246, 0.3); - border-radius: 6px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 14px; - transition: all 0.2s; - - &:hover { - background: rgba(139, 92, 246, 0.2); - border-color: rgba(139, 92, 246, 0.5); - transform: rotate(90deg); - } -`; - -const Icon = styled.span` - font-size: 18px; -`; - -const Title = styled.h2` - color: #ffffff; - font-size: 18px; - font-weight: 600; - margin: 0; -`; - -const BalanceSection = styled.div` - margin-bottom: 24px; -`; - -const Balance = styled.div` - color: #ffffff; - font-size: 36px; - font-weight: 700; - line-height: 1; - margin-bottom: 4px; -`; - -const LoadingBalance = styled.div` - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 4px; - - span { - color: #8b5cf6; - font-size: 18px; - font-weight: 500; - } -`; - -const ErrorBalance = styled.div` - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 4px; - - span { - color: #ef4444; - font-size: 18px; - font-weight: 500; - } -`; - -const RetryButton = styled.button` - background: #ef4444; - color: white; - border: none; - border-radius: 6px; - padding: 4px 12px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background: #dc2626; - } -`; - -const NetworkLabel = styled.div` - color: #8b5cf6; - font-size: 14px; - font-weight: 500; -`; - -const TopUpButton = styled.button` - background: #7c3aed; - color: #ffffff; - border: none; - border-radius: 8px; - padding: 12px 24px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - margin-bottom: 16px; - transition: background-color 0.2s; - - &:hover { - background: #6d28d9; - } -`; - -const Description = styled.p` - color: #ffffff; - font-size: 14px; - line-height: 1.5; - margin: 0 0 16px 0; -`; - -const SpendInfo = styled.div` - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; -`; - -const SpendLabel = styled.span` - color: #4ade80; - font-size: 14px; - font-weight: 500; -`; - -const SpendAmount = styled.span` - color: #4ade80; - font-size: 14px; - font-weight: 600; -`; - -const DetailedDescription = styled.p` - color: #9ca3af; - font-size: 12px; - line-height: 1.5; - margin: 0; - font-style: italic; -`; - export default UniversalGasTank; diff --git a/src/apps/gas-tank/components/shared.styles.ts b/src/apps/gas-tank/components/shared.styles.ts new file mode 100644 index 00000000..81e07ca9 --- /dev/null +++ b/src/apps/gas-tank/components/shared.styles.ts @@ -0,0 +1,61 @@ +import styled from 'styled-components'; + +// Shared colors +export const colors = { + background: '#1a1a1a', + border: '#333', + text: { + primary: '#ffffff', + secondary: '#9ca3af', + accent: '#8b5cf6', + }, + status: { + success: '#4ade80', + error: '#ef4444', + }, + button: { + primary: '#7c3aed', + primaryHover: '#6d28d9', + }, +}; + +// Shared typography +export const typography = { + title: ` + font-size: 18px; + font-weight: 600; + color: ${colors.text.primary}; + `, + body: ` + font-size: 14px; + line-height: 1.5; + color: ${colors.text.primary}; + `, + small: ` + font-size: 12px; + line-height: 1.5; + color: ${colors.text.secondary}; + `, +}; + +// Shared components +export const BaseContainer = styled.div` + background: ${colors.background}; + border: 1px solid ${colors.border}; +`; + +export const BaseHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; +`; + +export const BaseTitle = styled.h2` + ${typography.title}; + margin: 0; +`; + +export const IconWrapper = styled.span` + font-size: 18px; +`; \ No newline at end of file diff --git a/src/apps/gas-tank/constants/tokens.ts b/src/apps/gas-tank/constants/tokens.ts new file mode 100644 index 00000000..b27fa84e --- /dev/null +++ b/src/apps/gas-tank/constants/tokens.ts @@ -0,0 +1,15 @@ +import { isGnosisEnabled } from '../../../utils/blockchain'; + +const allStableCurrencies = [ + { chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }, + { chainId: 10, address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85' }, // USDC on Optimism + { chainId: 137, address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' }, // USDC on Polygon + { chainId: 8453, address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' }, // USDC on Base + { chainId: 42161, address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' }, // USDC on Arbitrum + { chainId: 56, address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d' }, // USDC on BNB Smart Chain + { chainId: 100, address: '0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0' }, // USDC on Gnosis +]; + +export const STABLE_CURRENCIES = allStableCurrencies.filter( + (currency) => isGnosisEnabled || currency.chainId !== 100 +); diff --git a/src/apps/gas-tank/hooks/useGasTankBalance.tsx b/src/apps/gas-tank/hooks/useGasTankBalance.tsx index 50d57e42..8d0046b0 100644 --- a/src/apps/gas-tank/hooks/useGasTankBalance.tsx +++ b/src/apps/gas-tank/hooks/useGasTankBalance.tsx @@ -21,7 +21,12 @@ interface UseGasTankBalanceReturn { refetch: () => void; } -const useGasTankBalance = (): UseGasTankBalanceReturn => { +interface UseGasTankBalanceOptions { + pauseAutoRefresh?: boolean; +} + +const useGasTankBalance = (options: UseGasTankBalanceOptions = {}): UseGasTankBalanceReturn => { + const { pauseAutoRefresh = false } = options; const walletAddress = useWalletAddress(); const [totalBalance, setTotalBalance] = useState(0); const [chainBalances, setChainBalances] = useState([]); @@ -94,17 +99,17 @@ const useGasTankBalance = (): UseGasTankBalanceReturn => { fetchGasTankBalance(); }, [fetchGasTankBalance]); - // Auto-refresh every 30 seconds when component is active - useEffect(() => { - if (!walletAddress) return; + // Auto-refresh disabled + // useEffect(() => { + // if (!walletAddress || pauseAutoRefresh) return; - const interval = setInterval(() => { - fetchGasTankBalance(); - }, 30000); // 30 seconds + // const interval = setInterval(() => { + // fetchGasTankBalance(); + // }, 30000); // 30 seconds - // Cleanup interval on component unmount - return () => clearInterval(interval); - }, [walletAddress, fetchGasTankBalance]); + // // Cleanup interval on component unmount + // return () => clearInterval(interval); + // }, [walletAddress, fetchGasTankBalance, pauseAutoRefresh]); return { totalBalance, diff --git a/src/apps/gas-tank/hooks/useTokenSearch.ts b/src/apps/gas-tank/hooks/useTokenSearch.ts new file mode 100644 index 00000000..d0b6ca9b --- /dev/null +++ b/src/apps/gas-tank/hooks/useTokenSearch.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { useGetSearchTokensQuery } from '../../../services/pillarXApiSearchTokens'; +import { MobulaChainNames, getChainId } from '../utils/constants'; + +export function useTokenSearch(props: { + isBuy: boolean; + chains: MobulaChainNames; +}) { + const [searchText, setSearchText] = useState(''); + const [debouncedSearchText, setDebouncedSearchText] = useState(''); + + const { + data: searchData, + isLoading: isSearchLoading, + isFetching, + } = useGetSearchTokensQuery( + { + searchInput: debouncedSearchText, + filterBlockchains: getChainId(props.chains), + }, + { + skip: !debouncedSearchText, + refetchOnFocus: false, + } + ); + + return { + searchText, + setSearchText, + searchData, + isSearchLoading, + isFetching, + }; +} diff --git a/src/apps/gas-tank/icon.png b/src/apps/gas-tank/icon.png index 0838beddb246fe5180ced0dccb5aa47a2e85df38..e86f5f68f213a8bd2cfe57b666e45bcc86889d71 100644 GIT binary patch literal 7527 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 FzW@mspEdvh literal 20211 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 Mi6lXngfV&l1FV|+M*si- diff --git a/src/apps/gas-tank/types/tokens.ts b/src/apps/gas-tank/types/tokens.ts new file mode 100644 index 00000000..098ccdc9 --- /dev/null +++ b/src/apps/gas-tank/types/tokens.ts @@ -0,0 +1,30 @@ +export type SelectedToken = { + name: string; + symbol: string; + usdValue: string; + logo: string; + dailyPriceChange: number; + chainId: number; + decimals: number; + address: string; +}; + +export type PayingToken = { + name: string; + symbol: string; + logo: string; + actualBal: string; + totalUsd: number; + totalRaw: string; + chainId: number; + address: string; +}; + +export enum SearchType { + MyHoldings = 'My Holdings', +} + +export enum SortType { + Up = 'Up', + Down = 'Down', +} diff --git a/src/apps/gas-tank/types/types.ts b/src/apps/gas-tank/types/types.ts new file mode 100644 index 00000000..ee8458b3 --- /dev/null +++ b/src/apps/gas-tank/types/types.ts @@ -0,0 +1,200 @@ +export type TransactionStatusState = + | 'Starting Transaction' + | 'Transaction Pending' + | 'Transaction Complete' + | 'Transaction Failed'; + +export type TransactionStep = + | 'Submitted' + | 'Pending' + | 'ResourceLock' + | 'Completed'; + +export type StepStatus = 'completed' | 'pending' | 'failed' | 'inactive'; + +export interface TokenInfo { + symbol: string; + name: string; + logo: string; + address?: string; +} + +export interface SellOffer { + tokenAmountToReceive: number; + minimumReceive: number; +} + +export interface PayingToken { + totalUsd: number; + name: string; + symbol: string; + logo: string; + actualBal: string; + totalRaw: string; + chainId: number; + address: string; +} + +export interface TokenDetails { + symbol: string; + name: string; + address: string; + chainId: number; + amount: string; + logo: string; + type: 'BUY_TOKEN' | 'SELL_TOKEN'; +} + +export interface BuyModeDetails { + usdAmount: string; + payingTokens: PayingToken[]; + totalPayingUsd: number; +} + +export interface TransactionStatusConfig { + icon: string; + containerClasses: string; + iconClasses: string; + color: string; +} + +export interface ButtonConfig { + bgColor: string; + textColor: string; + borderColor: string; + label: string; +} + +export interface ProgressStepConfig { + label: string; + order: number; +} + +export interface TransactionStatusProps { + closeTransactionStatus: () => void; + userOpHash: string; + chainId: number; + gasFee?: string; + // Transaction data from PreviewSell/PreviewBuy + isBuy?: boolean; + sellToken?: TokenInfo | null; + buyToken?: TokenInfo | null; + tokenAmount?: string; + sellOffer?: SellOffer | null; + payingTokens?: PayingToken[]; + usdAmount?: string; + // Externalized polling state + currentStatus: TransactionStatusState; + errorDetails: string; + submittedAt?: Date; + pendingCompletedAt?: Date; + blockchainTxHash?: string; + resourceLockTxHash?: string; + completedTxHash?: string; + completedChainId?: number; + resourceLockChainId?: number; + resourceLockCompletedAt?: Date; + isResourceLockFailed?: boolean; +} + +export interface TransactionDetailsProps { + onDone: () => void; + userOpHash: string; + chainId: number; + status: TransactionStatusState; + // Transaction data from PreviewSell/PreviewBuy + isBuy?: boolean; + sellToken?: TokenInfo | null; + buyToken?: TokenInfo | null; + tokenAmount?: string; + sellOffer?: SellOffer | null; + payingTokens?: PayingToken[]; + usdAmount?: string; + submittedAt?: Date; + pendingCompletedAt?: Date; + resourceLockCompletedAt?: Date; + txHash?: string; + gasFee?: string; + errorDetails?: string; + resourceLockTxHash?: string; + completedTxHash?: string; + resourceLockChainId?: number; + completedChainId?: number; + isResourceLockFailed?: boolean; +} + +export interface TransactionInfoProps { + status: TransactionStatusState; + userOpHash: string; + txHash?: string; + chainId: number; + gasFee?: string; + completedAt?: Date; + // Buy-specific fields + isBuy?: boolean; + resourceLockTxHash?: string; + resourceLockChainId?: number; + completedTxHash?: string; + completedChainId?: number; +} + +export interface ProgressStepProps { + step: TransactionStep; + status: StepStatus; + label: string; + isLast?: boolean; + showLine?: boolean; + lineStatus?: StepStatus; + timestamp?: string | React.ReactNode; +} + +export interface TransactionErrorBoxProps { + technicalDetails?: string; +} + +export interface UseClickOutsideOptions { + ref: React.RefObject; + callback: () => void; + condition: boolean; +} + +export interface UseKeyboardNavigationOptions { + onEscape: () => void; + onEnter?: () => void; + enabled?: boolean; +} + +export interface TechnicalDetails { + transactionType: 'BUY' | 'SELL'; + transactionHash: string; + hashType: 'bidHash' | 'userOpHash'; + chainId: number; + status: TransactionStatusState; + timestamp: string; + accountAddress: string; + token: TokenDetails | null; + sellOffer: SellOffer | null; + buyMode: BuyModeDetails | null; + transactionHashes: { + [key: string]: string; + }; + chains: { + mainChainId: number; + resourceLockChainId: string | number; + completedChainId: string | number; + }; + timestamps: { + [key: string]: string; + }; + error: { + details: string; + isResourceLockFailed: boolean; + failureStep: string; + }; + gas: { + fee: string; + }; + stepStatus: { + [key: string]: StepStatus; + }; +} diff --git a/src/apps/gas-tank/utils/constants.ts b/src/apps/gas-tank/utils/constants.ts new file mode 100644 index 00000000..03b6c1bf --- /dev/null +++ b/src/apps/gas-tank/utils/constants.ts @@ -0,0 +1,70 @@ +import { CompatibleChains, isGnosisEnabled } from '../../../utils/blockchain'; + +const allMobulaChainNames = [ + 'Ethereum', + 'Polygon', + 'Base', + 'XDAI', + 'BNB Smart Chain (BEP20)', + 'Arbitrum', + 'Optimistic', +]; + +export const MOBULA_CHAIN_NAMES = allMobulaChainNames.filter( + (name) => isGnosisEnabled || name !== 'XDAI' +); + +export enum MobulaChainNames { + Ethereum = 'Ethereum', + Polygon = 'Polygon', + Base = 'Base', + XDAI = 'XDAI', + BNB_Smart_Chain_BEP20 = 'BNB Smart Chain (BEP20)', + Arbitrum = 'Arbitrum', + Optimistic = 'Optimistic', + All = 'All', +} + +export const getChainId = (chain: MobulaChainNames) => { + switch (chain) { + case MobulaChainNames.Ethereum: + return '1'; + case MobulaChainNames.Polygon: + return '137'; + case MobulaChainNames.Base: + return '8453'; + case MobulaChainNames.XDAI: + return '100'; + case MobulaChainNames.BNB_Smart_Chain_BEP20: + return '56'; + case MobulaChainNames.Arbitrum: + return '42161'; + case MobulaChainNames.Optimistic: + return '10'; + default: + return CompatibleChains.reduce((acc, item, index) => { + return acc + (index > 0 ? ',' : '') + item.chainId; + }, ''); + } +}; + +export const getChainName = (chainId: number) => { + switch (chainId) { + case 1: + return MobulaChainNames.Ethereum; + case 137: + return MobulaChainNames.Polygon; + case 8453: + return MobulaChainNames.Base; + case 100: + return MobulaChainNames.XDAI; + case 56: + return MobulaChainNames.BNB_Smart_Chain_BEP20; + case 42161: + return MobulaChainNames.Arbitrum; + case 10: + return MobulaChainNames.Optimistic; + default: + return ''; + } +}; diff --git a/src/apps/gas-tank/utils/number.ts b/src/apps/gas-tank/utils/number.ts new file mode 100644 index 00000000..fcaa39fb --- /dev/null +++ b/src/apps/gas-tank/utils/number.ts @@ -0,0 +1,53 @@ +export function formatBigNumber(num: number): string { + if (num < 1_000) { + return num.toString(); + } + if (num < 1_000_000) { + return `${(num / 1_000).toFixed(3).replace(/\.?0+$/, '')}K`; + } + if (num >= 1_000_000 && num < 1_000_000_000) { + return `${(num / 1_000_000).toFixed(3).replace(/\.?0+$/, '')}M`; + } + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(3).replace(/\.?0+$/, '')}B`; + } + return num.toString(); +} + +export function parseNumberString(input: string): number { + const match = input.match(/^([\d,.]+)([KMB]?)$/i); + if (!match) return 0; + + const [, num, unit] = match; + let value = parseFloat(num.replace(/,/g, '')); + + switch (unit.toUpperCase()) { + case 'K': + value *= 1_000; + break; + case 'M': + value *= 1_000_000; + break; + case 'B': + value *= 1_000_000_000; + break; + default: + value *= 1; + break; + } + return value; +} + +export function bigIntPow(base: bigint, exponent: bigint): bigint { + let result = BigInt(1); + while (exponent > BigInt(0)) { + if (exponent % BigInt(2) === BigInt(1)) { + result *= base; + } + // eslint-disable-next-line no-param-reassign + base *= base; + // eslint-disable-next-line no-param-reassign + exponent /= BigInt(2); + } + return result; +} diff --git a/src/apps/gas-tank/utils/parseSearchData.ts b/src/apps/gas-tank/utils/parseSearchData.ts new file mode 100644 index 00000000..de41dc0a --- /dev/null +++ b/src/apps/gas-tank/utils/parseSearchData.ts @@ -0,0 +1,99 @@ +/* eslint-disable no-restricted-syntax */ +import { + PairResponse, + Projection, + TokenAssetResponse, + TokensMarketData, +} from '../../../types/api'; +import { + getChainName, + MOBULA_CHAIN_NAMES, + MobulaChainNames, +} from './constants'; +import { parseNumberString } from './number'; + +export type Asset = { + name: string; + symbol: string; + logo: string | null; + mCap: number | undefined; + volume: number | undefined; + price: number | null; + liquidity: number | undefined; + chain: string; + decimals: number; + contract: string; + priceChange24h: number | null; + timestamp?: number; +}; + +export function parseAssetData( + asset: TokenAssetResponse, + chains: MobulaChainNames +): Asset[] { + const result: Asset[] = []; + const { blockchains, contracts, decimals } = asset; + for (let i = 0; i < blockchains.length; i += 1) { + if ( + MOBULA_CHAIN_NAMES.includes(blockchains[i]) && + (chains === MobulaChainNames.All || chains === blockchains[i]) + ) { + result.push({ + name: asset.name, + symbol: asset.symbol, + logo: asset.logo, + mCap: asset.market_cap, + volume: asset.volume, + price: asset.price, + liquidity: asset.liquidity, + chain: blockchains[i], + decimals: decimals[i], + contract: contracts[i], + priceChange24h: asset.price_change_24h, + }); + } + } + + return result; +} + +export function parseTokenData(asset: TokenAssetResponse): Asset[] { + const result: Asset[] = []; + const { blockchains, decimals, contracts } = asset; + for (let i = 0; i < blockchains.length; i += 1) { + if (MOBULA_CHAIN_NAMES.includes(blockchains[i])) { + result.push({ + name: asset.name, + symbol: asset.symbol, + logo: asset.logo, + mCap: asset.market_cap, + volume: asset.volume_24h, + price: asset.price, + liquidity: asset.liquidity, + chain: blockchains[i], + decimals: decimals[i], + contract: contracts[i], + priceChange24h: asset.price_change_24h, + }); + } + } + return result; +} + +export function parseSearchData( + searchData: TokenAssetResponse[] | PairResponse[], + chains: MobulaChainNames +) { + const assets: Asset[] = []; + const markets: Asset[] = []; + searchData.forEach((item) => { + if (item.type === 'asset') { + assets.push(...parseAssetData(item as TokenAssetResponse, chains)); + } else if (item.type === 'token') { + assets.push(...parseTokenData(item as TokenAssetResponse)); + } + }); + + return { assets, markets }; +} + diff --git a/src/apps/gas-tank/utils/time.ts b/src/apps/gas-tank/utils/time.ts new file mode 100644 index 00000000..a07d86e1 --- /dev/null +++ b/src/apps/gas-tank/utils/time.ts @@ -0,0 +1,25 @@ +export function formatElapsedTime(epochSeconds?: number): string { + if (!epochSeconds) return ''; + const now = Math.floor(Date.now() / 1000); + const diff = now - epochSeconds; + + const minutes = Math.floor(diff / 60); + const hours = Math.floor(diff / 3600); + const days = Math.floor(diff / 86400); + const months = Math.floor(diff / (30 * 86400)); + const years = Math.floor(diff / (365 * 86400)); + + if (diff < 3600) { + return `${minutes}m ago`; + } + if (diff < 86400) { + return `${hours}h ago`; + } + if (diff < 30 * 86400) { + return `${days}d ago`; + } + if (diff < 12 * 30 * 86400) { + return `${months}mo ago`; + } + return `${years}y ago`; +} diff --git a/src/apps/pillarx-app/components/GasTankPaymasterTile/GasTankPaymasterTile.tsx b/src/apps/pillarx-app/components/GasTankPaymasterTile/GasTankPaymasterTile.tsx new file mode 100644 index 00000000..157620e9 --- /dev/null +++ b/src/apps/pillarx-app/components/GasTankPaymasterTile/GasTankPaymasterTile.tsx @@ -0,0 +1,410 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { useWalletAddress } from '@etherspot/transaction-kit'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +// components +import { useGasTankHistory } from '../../../gas-tank/components/GasTankHistory'; +import useGasTankBalance from '../../../gas-tank/hooks/useGasTankBalance'; + +// assets +import gasTankIcon from '../../../gas-tank/assets/gas-tank-icon.png'; + +// types +interface HistoryEntry { + id: string; + date: string; + type: 'Top-up' | 'Spend'; + amount: string; + token: { + symbol: string; + value: string; + icon: string; + chainId: string; + }; +} + +const GasTankPaymasterTile = () => { + const walletAddress = useWalletAddress(); + const navigate = useNavigate(); + + const { + totalBalance, + isLoading: isBalanceLoading, + error: balanceError, + } = useGasTankBalance({ pauseAutoRefresh: false }); + + const { + historyData, + loading: isHistoryLoading, + error: historyError, + } = useGasTankHistory(walletAddress, { pauseAutoRefresh: false }); + + const handleTileClick = () => { + navigate('/gas-tank'); + }; + + // Show only the first 5 entries + const displayedHistory = historyData.slice(0, 5); + const loading = isBalanceLoading || isHistoryLoading; + const error = balanceError || historyError; + + return ( + +
+ + Gas Tank Paymaster + View All → +
+ + + + + {balanceError ? ( + + Error loading balance + + ) : isBalanceLoading ? ( + + ) : ( + <> + ${totalBalance.toFixed(2)} + On All Networks + + )} + + + + + {isHistoryLoading ? ( + + {Array.from({ length: 3 }).map((_, index) => ( + + + + + + + + ))} + + ) : historyError ? ( + + Unable to load gas tank history + + ) : displayedHistory.length === 0 ? ( + + No transactions yet + + ) : ( + + + # + Date + Type + Amount + Token + + + + {displayedHistory.map((entry) => ( + + {entry.id} + {entry.date} + + + {entry.type} + + + + + {entry.amount} + + + + + + + {entry.token.value} + {entry.token.symbol} + + + + + ))} + + + )} + + +
+ ); +}; + +// Styled Components +const TileContainer = styled.div` + background: #1A1B1E; + border-radius: 16px; + padding: 24px; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid rgba(255, 255, 255, 0.05); + + &:hover { + background: #1E1F23; + border-color: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); + } +`; + +const Header = styled.div` + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +`; + +const IconImage = styled.img` + width: 24px; + height: 24px; + object-fit: contain; +`; + +const Title = styled.h2` + font-size: 20px; + font-weight: 600; + margin: 0; + color: #FFFFFF; + flex: 1; +`; + +const ViewAllButton = styled.span` + color: #8B5CF6; + font-size: 14px; + font-weight: 500; +`; + +const ContentContainer = styled.div` + display: flex; + gap: 32px; + + @media (max-width: 768px) { + flex-direction: column; + gap: 24px; + } +`; + +const LeftSection = styled.div` + flex: 1; + min-width: 200px; +`; + +const RightSection = styled.div` + flex: 2; +`; + +const BalanceSection = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const BalanceAmount = styled.div` + font-size: 48px; + font-weight: 600; + color: #FFFFFF; + line-height: 1; +`; + +const NetworkLabel = styled.div` + color: #3B82F6; + font-size: 12px; + font-weight: 500; +`; + +const LoadingBalance = styled.div` + width: 180px; + height: 48px; + background: linear-gradient( + 90deg, + #2A2A2A 0%, + rgba(139, 92, 246, 0.1) 50%, + #2A2A2A 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite linear; + border-radius: 4px; + + @keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } +`; + +const ErrorBalance = styled.div` + color: #EF4444; + font-size: 14px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +const LoadingContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const LoadingRow = styled.div` + display: flex; + gap: 16px; + align-items: center; +`; + +const LoadingCell = styled.div<{ width: string }>` + width: ${({ width }) => width}; + height: 16px; + background: linear-gradient( + 90deg, + #2A2A2A 0%, + rgba(139, 92, 246, 0.1) 50%, + #2A2A2A 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite linear; + border-radius: 4px; + + @keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } +`; + +const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 32px; +`; + +const ErrorText = styled.p` + color: #EF4444; + font-size: 14px; + margin: 0; +`; + +const EmptyContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 32px; +`; + +const EmptyText = styled.p` + color: #9CA3AF; + font-size: 14px; + margin: 0; +`; + +const TableContainer = styled.div` + width: 100%; +`; + +const TableHeader = styled.div` + display: flex; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 8px; +`; + +const HeaderCell = styled.div<{ width: string }>` + width: ${({ width }) => width}; + color: #9CA3AF; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const TableBody = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const TableRow = styled.div` + display: flex; + align-items: center; + padding: 8px 0; + border-radius: 6px; + transition: background-color 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.02); + } +`; + +const Cell = styled.div<{ width: string }>` + width: ${({ width }) => width}; + display: flex; + align-items: center; + color: #FFFFFF; + font-size: 14px; +`; + +const TypeBadge = styled.span<{ $isDeposit: boolean }>` + background: ${({ $isDeposit }) => + $isDeposit ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)'}; + color: ${({ $isDeposit }) => + $isDeposit ? '#22C55E' : '#EF4444'}; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +`; + +const Amount = styled.span<{ $isDeposit: boolean }>` + color: ${({ $isDeposit }) => + $isDeposit ? '#22C55E' : '#EF4444'}; + font-weight: 600; +`; + +const TokenInfo = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const TokenIcon = styled.img` + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; +`; + +const TokenDetails = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +`; + +const TokenValue = styled.span` + color: #FFFFFF; + font-size: 12px; + font-weight: 500; +`; + +const TokenSymbol = styled.span` + color: #9CA3AF; + font-size: 10px; +`; + +export default GasTankPaymasterTile; \ No newline at end of file diff --git a/src/apps/pillarx-app/index.tsx b/src/apps/pillarx-app/index.tsx index b2ef6a2b..80689aa4 100644 --- a/src/apps/pillarx-app/index.tsx +++ b/src/apps/pillarx-app/index.tsx @@ -19,6 +19,7 @@ import { componentMap } from './utils/configComponent'; // components import AnimatedTile from './components/AnimatedTile/AnimatedTitle'; +import GasTankPaymasterTile from './components/GasTankPaymasterTile/GasTankPaymasterTile'; import SkeletonTiles from './components/SkeletonTile/SkeletonTile'; import Body from './components/Typography/Body'; import WalletPortfolioTile from './components/WalletPortfolioTile/WalletPortfolioTile'; @@ -194,6 +195,7 @@ const App = () => { className="flex flex-col gap-[40px] tablet:gap-[28px] mobile:gap-[32px]" > + {DisplayHomeFeedTiles} {(isHomeFeedFetching || isHomeFeedLoading) && page === 1 && ( <> diff --git a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx index 87e28e29..497e45d7 100644 --- a/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx +++ b/src/components/BottomMenuModal/SendModal/SendModalTokensTabView.tsx @@ -221,144 +221,108 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { walletPortfolioDataError, ]); - const feeTypeOptions = [ - { - id: 'Native Token', - title: 'Native Token', - type: 'token', - value: '', - }, - ]; - - const [feeType, setFeeType] = React.useState(feeTypeOptions); + // Start with Native Token as the base fee type option + const [feeType, setFeeType] = React.useState([ + { id: 'Native Token', title: 'Native Token', type: 'token', value: '' } + ]); useEffect(() => { if (!walletPortfolio) return; const tokens = convertPortfolioAPIResponseToToken(walletPortfolio); if (!selectedAsset) return; setFetchingBalances(true); - getGasTankBalance(accountAddress ?? '').then((res) => { - feeTypeOptions.push({ - id: 'Gas Tank Paymaster', - title: 'Gas Tank Paymaster', - type: 'token', - value: '', - }); - if (res) { - setGasTankBalance(res); - if (res > 0) { - setFeeType(feeTypeOptions); - setIsPaymaster(true); - setPaymasterContext({ - mode: 'gasTankPaymaster', + + // Start with Native Token + const availableFeeTypes = [ + { id: 'Native Token', title: 'Native Token', type: 'token', value: '' } + ]; + + // Check for gasless tokens first + setQueryString(`?chainId=${selectedAsset.chainId}`); + + // Run both checks in parallel + Promise.all([ + getGasTankBalance(accountAddress ?? ''), + getAllGaslessPaymasters(selectedAsset.chainId, tokens) + ]).then(([resRaw, paymasterObject]) => { + const res = resRaw ?? 0; + setGasTankBalance(res); + setFetchingBalances(false); + setPaymasterContext(null); + + // Add Gas Tank Paymaster if balance exists + if (res > 0) { + availableFeeTypes.push({ + id: 'Gas Tank Paymaster', + title: 'Gas Tank Paymaster', + type: 'token', + value: '' + }); + } + if (paymasterObject) { + const nativeToken = tokens.filter( + (token: Token) => + isNativeToken(token.contract) && + chainNameToChainIdTokensData(token.blockchain) === + selectedAsset.chainId + ); + if (nativeToken.length > 0) { + setNativeAssetPrice(nativeToken[0]?.price || 0); + } + const feeOptions = paymasterObject + .map((item) => { + const tokenData = tokens.find( + (token) => token.contract.toLowerCase() === item.gasToken.toLowerCase() + ); + if (tokenData) + return { + id: `${item.gasToken}-${item.chainId}-${item.paymasterAddress}-${tokenData.decimals}`, + type: 'token', + title: tokenData.name, + imageSrc: tokenData.logo, + chainId: chainNameToChainIdTokensData(tokenData.blockchain), + value: tokenData.balance, + price: tokenData.price, + asset: { + ...tokenData, + contract: item.gasToken, + decimals: tokenData.decimals, + }, + balance: tokenData.balance ?? 0, + } as TokenAssetSelectOption; + }) + .filter((value): value is TokenAssetSelectOption => value !== undefined); + + if (feeOptions.length > 0) { + // Add Gasless option if we have gasless tokens + availableFeeTypes.push({ + id: 'Gasless', + title: 'Gasless', + type: 'token', + value: '' }); - setSelectedFeeType('Gas Tank Paymaster'); - setFetchingBalances(false); + setFeeType(availableFeeTypes); + setFeeAssetOptions(feeOptions); + setSelectedFeeType('Gasless'); + setDefaultSelectedFeeTypeId('Gasless'); + setIsPaymaster(true); } else { - setIsPaymaster(false); - setPaymasterContext(null); + setFeeType(availableFeeTypes); + setFeeAssetOptions([]); setSelectedFeeType('Native Token'); setDefaultSelectedFeeTypeId('Native Token'); - setFetchingBalances(false); + setIsPaymaster(false); } } else { - setIsPaymaster(false); - setFetchingBalances(false); - setPaymasterContext(null); + setFeeType(availableFeeTypes); + setFeeAssetOptions([]); setSelectedFeeType('Native Token'); setDefaultSelectedFeeTypeId('Native Token'); + setIsPaymaster(false); } }); - setQueryString(`?chainId=${selectedAsset.chainId}`); - getAllGaslessPaymasters(selectedAsset.chainId, tokens).then( - (paymasterObject) => { - if (paymasterObject) { - const nativeToken = tokens.filter( - (token: Token) => - isNativeToken(token.contract) && - chainNameToChainIdTokensData(token.blockchain) === - selectedAsset.chainId - ); - if (nativeToken.length > 0) { - setNativeAssetPrice(nativeToken[0]?.price || 0); - } - const feeOptions = paymasterObject - .map( - (item: { - gasToken: string; - chainId: number; - epVersion: string; - paymasterAddress: string; - // eslint-disable-next-line consistent-return, array-callback-return - }) => { - const tokenData = tokens.find( - (token: Token) => - token.contract.toLowerCase() === item.gasToken.toLowerCase() - ); - if (tokenData) - return { - id: `${item.gasToken}-${item.chainId}-${item.paymasterAddress}-${tokenData.decimals}`, - type: 'token', - title: tokenData.name, - imageSrc: tokenData.logo, - chainId: chainNameToChainIdTokensData(tokenData.blockchain), - value: tokenData.balance, - price: tokenData.price, - asset: { - ...tokenData, - contract: item.gasToken, - decimals: tokenData.decimals, - }, - balance: tokenData.balance ?? 0, - } as TokenAssetSelectOption; - } - ) - .filter( - (value): value is TokenAssetSelectOption => value !== undefined - ); - if (feeOptions && feeOptions.length > 0 && feeOptions[0]) { - setFeeType(feeTypeOptions); - setFeeAssetOptions(feeOptions); - // get Skandha gas price - getGasPrice(selectedAsset.chainId).then((res) => { - setGasPrice(res); - }); - setSelectedFeeAsset({ - token: feeOptions[0].asset.contract, - decimals: feeOptions[0].asset.decimals, - tokenPrice: feeOptions[0].asset.price?.toString(), - balance: feeOptions[0].value?.toString(), - }); - feeTypeOptions.push({ - id: 'Gasless', - title: 'Gasless', - type: 'token', - value: '', - }); - setSelectedPaymasterAddress(feeOptions[0].id.split('-')[2]); - if (selectedFeeType === 'Native Token') { - setPaymasterContext({ - mode: 'commonerc20', - token: feeOptions[0].asset.contract, - }); - setIsPaymaster(true); - setDefaultSelectedFeeTypeId('Gasless'); - } - setFeeType(feeTypeOptions); - } else { - setIsPaymaster(false); - setPaymasterContext(null); - setFeeAssetOptions([]); - } - } else { - setPaymasterContext(null); - setIsPaymaster(false); - setFeeAssetOptions([]); - } - } - ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAsset, walletPortfolio]); + }, [selectedAsset?.chainId]); const setApprovalData = async (gasCost: number) => { if (selectedFeeAsset && gasPrice && gasCost) { @@ -546,6 +510,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { Number(amount) > maxAmountAvailable; const onSend = async (ignoreSafetyWarning?: boolean) => { + const sendId = `send_token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Start Sentry transaction for send flow @@ -1317,6 +1282,10 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { }); const paymasterAddress = value.id.split('-')[2]; setSelectedPaymasterAddress(paymasterAddress); + setPaymasterContext({ + mode: 'commonerc20', + token: tokenAddress, + }); }; const handleOnChangeFeeAsset = (value: SelectOption) => { @@ -1402,7 +1371,7 @@ const SendModalTokensTabView = ({ payload }: { payload?: SendModalData }) => { /> - {feeType.length > 0 && isPaymaster && ( + {feeType.length > 0 && ( <>