diff --git a/src/app/page.tsx b/src/app/page.tsx index 181b569..be37c93 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { SwapForm } from '@/components/swap-form'; +import { StakeForm } from '@/components/stake-form'; export default function HomePage() { const { t } = useTranslation(); @@ -20,10 +21,7 @@ export default function HomePage() { -
-

{t('stake.title')}

-

{t('stake.comingSoon')}

-
+
diff --git a/src/components/stake-form.tsx b/src/components/stake-form.tsx new file mode 100644 index 0000000..ef05265 --- /dev/null +++ b/src/components/stake-form.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { ChevronDown, CheckCircle, XCircle, Loader2 } from 'lucide-react'; +import { useAccount } from 'wagmi'; +import { useGetAllPools, useTokenDetails, useSwapMintAmounts, type PoolDetails, type TokenDetails } from '@/hooks/usePartyPlanner'; +import { useSwapMint } from '@/hooks/usePartyPool'; +import { formatUnits, parseUnits } from 'viem'; +import IPartyPoolABI from '@/contracts/IPartyPoolABI'; + +type TransactionStatus = 'idle' | 'pending' | 'success' | 'error'; + +export function StakeForm() { + const { t } = useTranslation(); + const { isConnected, address } = useAccount(); + const [stakeAmount, setStakeAmount] = useState(''); + const [selectedPool, setSelectedPool] = useState(null); + const [selectedToken, setSelectedToken] = useState(null); + const [isPoolDropdownOpen, setIsPoolDropdownOpen] = useState(false); + const [isTokenDropdownOpen, setIsTokenDropdownOpen] = useState(false); + const [transactionStatus, setTransactionStatus] = useState('idle'); + const [transactionError, setTransactionError] = useState(null); + const poolDropdownRef = useRef(null); + const tokenDropdownRef = useRef(null); + + // Fetch all pools using the new hook + const { poolDetails, loading: poolsLoading } = useGetAllPools(); + + // Get token details for the user + const { tokenDetails, loading: tokensLoading } = useTokenDetails(address); + + // Initialize swap mint hook + const { executeSwapMint, isSwapMinting } = useSwapMint(); + + // Get available tokens for staking based on selected pool + const availableTokensForPool = selectedPool && tokenDetails + ? tokenDetails.filter(token => + selectedPool.tokens.some(poolToken => + poolToken.toLowerCase() === token.address.toLowerCase() + ) + ) + : []; + + // Check if amount exceeds balance + const isAmountExceedingBalance = useMemo(() => { + if (!stakeAmount || !selectedToken) return false; + + try { + const amountInWei = parseUnits(stakeAmount, selectedToken.decimals); + return amountInWei > selectedToken.balance; + } catch { + // If parseUnits fails (invalid input), don't show error + return false; + } + }, [stakeAmount, selectedToken]); + + // Get the input token index in the selected pool + const inputTokenIndex = useMemo(() => { + if (!selectedPool || !selectedToken) return undefined; + + const index = selectedPool.tokens.findIndex( + token => token.toLowerCase() === selectedToken.address.toLowerCase() + ); + + return index !== -1 ? index : undefined; + }, [selectedPool, selectedToken]); + + // Parse the stake amount to Wei + const maxAmountIn = useMemo(() => { + if (!stakeAmount || !selectedToken) return undefined; + + try { + return parseUnits(stakeAmount, selectedToken.decimals); + } catch { + return undefined; + } + }, [stakeAmount, selectedToken]); + + // Fetch swap mint amounts + const { swapMintAmounts, loading: swapMintLoading } = useSwapMintAmounts( + selectedPool?.address, + inputTokenIndex, + maxAmountIn + ); + + // Close dropdowns when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (poolDropdownRef.current && !poolDropdownRef.current.contains(event.target as Node)) { + setIsPoolDropdownOpen(false); + } + if (tokenDropdownRef.current && !tokenDropdownRef.current.contains(event.target as Node)) { + setIsTokenDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleStake = async () => { + if (!selectedPool || !selectedToken || !stakeAmount || inputTokenIndex === undefined || !maxAmountIn) { + console.error('Missing required stake parameters'); + return; + } + + setTransactionStatus('pending'); + setTransactionError(null); + + try { + // Execute the swap mint transaction + await executeSwapMint( + selectedPool.address, + selectedToken.address, + inputTokenIndex, + maxAmountIn + ); + + setTransactionStatus('success'); + } catch (err) { + console.error('Stake failed:', err); + setTransactionError(err instanceof Error ? err.message : 'Transaction failed'); + setTransactionStatus('error'); + } + }; + + const handleCloseModal = () => { + if (transactionStatus === 'success') { + // Clear the form after successful stake + setStakeAmount(''); + setSelectedPool(null); + setSelectedToken(null); + } + setTransactionStatus('idle'); + setTransactionError(null); + }; + + return ( + + +
+ Stake +
+
+ + {/* Pool Selection */} +
+
+ +
+
+ + {isPoolDropdownOpen && ( +
+ {poolDetails && poolDetails.length > 0 ? ( + poolDetails.map((pool) => ( + + )) + ) : ( +
+ {poolsLoading ? 'Loading pools...' : 'No pools available'} +
+ )} +
+ )} +
+
+ + {/* Token Selection */} +
+
+ +
+
+ +
+ {t('swap.balance')}: {selectedToken ? formatUnits(selectedToken.balance, selectedToken.decimals) : '0.00'} +
+ {isTokenDropdownOpen && ( +
+ {availableTokensForPool.length > 0 ? ( + availableTokensForPool.map((token) => ( + + )) + ) : selectedPool ? ( +
+ {tokensLoading ? 'Loading tokens...' : 'No tokens available for this pool'} +
+ ) : ( +
+ Select a pool first +
+ )} +
+ )} +
+
+ + {/* Amount Input */} +
+
+ +
+ setStakeAmount(e.target.value)} + className="text-2xl h-16" + disabled={!selectedToken || !selectedPool} + /> + {isAmountExceedingBalance && ( +

+ {t('stake.insufficientBalance')} +

+ )} +
+ + {/* Swap Mint Amounts Display */} + {swapMintAmounts && selectedToken && !isAmountExceedingBalance && ( +
+
+ {t('stake.amountUsed')}: + + {swapMintLoading ? 'Calculating...' : `${formatUnits(swapMintAmounts.amountInUsed, selectedToken.decimals)} ${selectedToken.symbol}`} + +
+
+ {t('stake.fee')}: + + {swapMintLoading ? 'Calculating...' : `${formatUnits(swapMintAmounts.fee, selectedToken.decimals)} ${selectedToken.symbol}`} + +
+
+ {t('stake.lpMinted')}: + + {swapMintLoading ? 'Calculating...' : `${formatUnits(swapMintAmounts.lpMinted, 18)} ${selectedPool?.symbol || 'LP'}`} + +
+
+ )} + + {/* Stake Button */} + +
+ + {/* Transaction Modal Overlay */} + {transactionStatus !== 'idle' && ( +
+
+ {transactionStatus === 'pending' && ( +
+ +

Approving Stake

+

+ Staking {stakeAmount} {selectedToken?.symbol} to {selectedPool?.symbol} +

+

+ Please confirm the transactions in your wallet +

+
+ )} + + {transactionStatus === 'success' && ( +
+ +

Stake Confirmed!

+

+ Successfully staked {stakeAmount} {selectedToken?.symbol} to {selectedPool?.symbol} +

+ +
+ )} + + {transactionStatus === 'error' && ( +
+ +

Stake Failed

+

+ {transactionError || 'Transaction failed'} +

+ +
+ )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/swap-form.tsx b/src/components/swap-form.tsx index d39048b..5817c43 100644 --- a/src/components/swap-form.tsx +++ b/src/components/swap-form.tsx @@ -5,18 +5,18 @@ import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { ArrowDownUp, ChevronDown, Settings } from 'lucide-react'; +import { ArrowDownUp, ChevronDown, Settings, CheckCircle, XCircle, Loader2 } from 'lucide-react'; import { useAccount } from 'wagmi'; import { useTokenDetails, useGetPoolsByToken, type TokenDetails } from '@/hooks/usePartyPlanner'; import { useSwapAmounts, useSwap, selectBestSwapRoute } from '@/hooks/usePartyPool'; import { formatUnits, parseUnits } from 'viem'; import { SwapReviewModal } from './swap-review-modal'; -import { useToast } from '@/components/ui/toast'; + +type TransactionStatus = 'idle' | 'pending' | 'success' | 'error'; export function SwapForm() { const { t } = useTranslation(); const { isConnected, address } = useAccount(); - const { addToast, updateToast } = useToast(); const [fromAmount, setFromAmount] = useState(''); const [toAmount, setToAmount] = useState(''); const [selectedFromToken, setSelectedFromToken] = useState(null); @@ -26,6 +26,8 @@ export function SwapForm() { const [maxSlippage, setMaxSlippage] = useState('5.5'); // Default 5.5% const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [transactionStatus, setTransactionStatus] = useState('idle'); + const [transactionError, setTransactionError] = useState(null); const fromDropdownRef = useRef(null); const toDropdownRef = useRef(null); @@ -95,12 +97,8 @@ export function SwapForm() { return; } - // Show swapping toast - const toastId = addToast({ - title: 'Swapping', - description: `${fromAmount} ${selectedFromToken.symbol} → ${toAmount} ${selectedToToken.symbol}`, - type: 'loading', - }); + setTransactionStatus('pending'); + setTransactionError(null); try { // Use the shared helper to select the best swap route @@ -108,11 +106,8 @@ export function SwapForm() { if (!bestRoute) { console.error('No valid swap route found'); - updateToast(toastId, { - title: 'Swap Failed', - description: 'No valid swap route found', - type: 'error', - }); + setTransactionError('No valid swap route found'); + setTransactionStatus('error'); return; } @@ -129,26 +124,24 @@ export function SwapForm() { currentSlippage ); - // Update toast to success - updateToast(toastId, { - title: 'Swap Confirmed', - description: `Successfully swapped ${fromAmount} ${selectedFromToken.symbol} to ${toAmount} ${selectedToToken.symbol}`, - type: 'success', - }); + setTransactionStatus('success'); + } catch (err) { + console.error('Swap failed:', err); + setTransactionError(err instanceof Error ? err.message : 'Transaction failed'); + setTransactionStatus('error'); + } + }; + const handleCloseModal = () => { + if (transactionStatus === 'success') { // Clear the form after successful swap setFromAmount(''); setToAmount(''); setSelectedFromToken(null); setSelectedToToken(null); - } catch (err) { - console.error('Swap failed:', err); - updateToast(toastId, { - title: 'Swap Failed', - description: err instanceof Error ? err.message : 'Transaction failed', - type: 'error', - }); } + setTransactionStatus('idle'); + setTransactionError(null); }; const switchTokens = () => { @@ -177,7 +170,7 @@ export function SwapForm() { }, [swapAmounts, selectedFromToken, selectedToToken, fromAmount, currentSlippage, isConnected]); return ( - +
Swap @@ -428,6 +421,59 @@ export function SwapForm() { }} isSwapping={isSwapping} /> + + {/* Transaction Modal Overlay */} + {transactionStatus !== 'idle' && ( +
+
+ {transactionStatus === 'pending' && ( +
+ +

Approving Swap

+

+ Swapping {fromAmount} {selectedFromToken?.symbol} → {toAmount} {selectedToToken?.symbol} +

+

+ Please confirm the transactions in your wallet +

+
+ )} + + {transactionStatus === 'success' && ( +
+ +

Swap Confirmed!

+

+ Successfully swapped {fromAmount} {selectedFromToken?.symbol} to {toAmount} {selectedToToken?.symbol} +

+ +
+ )} + + {transactionStatus === 'error' && ( +
+ +

Swap Failed

+

+ {transactionError || 'Transaction failed'} +

+ +
+ )} +
+
+ )} ); } diff --git a/src/hooks/usePartyPlanner.ts b/src/hooks/usePartyPlanner.ts index d9af328..781ddc1 100644 --- a/src/hooks/usePartyPlanner.ts +++ b/src/hooks/usePartyPlanner.ts @@ -5,6 +5,7 @@ import { usePublicClient } from 'wagmi'; import chainInfo from '@/contracts/liqp-deployments.json'; import IPartyPlannerABI from '@/contracts/IPartyPlannerABI'; import IPartyPoolABI from '@/contracts/IPartyPoolABI'; +import IPartyPoolViewerABI from '@/contracts/IPartyPoolViewerABI'; import { ERC20ABI } from '@/contracts/ERC20ABI'; export function useGetAllTokens(offset: number = 0, limit: number = 100) { @@ -324,4 +325,201 @@ export function useTokenDetails(userAddress: `0x${string}` | undefined) { loading, error, }; +} + +export interface PoolDetails { + address: `0x${string}`; + name: string; + symbol: string; + tokens: readonly `0x${string}`[]; +} + +export function useGetAllPools(offset: number = 0, limit: number = 100) { + const publicClient = usePublicClient(); + const [mounted, setMounted] = useState(false); + const [pools, setPools] = useState(null); + const [poolDetails, setPoolDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Handle hydration for Next.js static export + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + + const fetchPools = async () => { + if (!publicClient) { + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + // Get chain ID and contract address + const chainId = await publicClient.getChainId(); + const address = (chainInfo as Record)[chainId.toString()]?.v1?.PartyPlanner; + + if (!address) { + setError('IPartyPlanner contract not found for current chain'); + setPools([]); + setPoolDetails([]); + return; + } + + // Call getAllPools function + const result = await publicClient.readContract({ + address: address as `0x${string}`, + abi: IPartyPlannerABI, + functionName: 'getAllPools', + args: [BigInt(offset), BigInt(limit)], + }); + + setPools(result); + + // Fetch details for each pool + const details: PoolDetails[] = []; + for (const poolAddress of result) { + try { + const [name, symbol, tokens] = await Promise.all([ + publicClient.readContract({ + address: poolAddress, + abi: ERC20ABI, + functionName: 'name', + }).catch(() => 'Unknown Pool'), + publicClient.readContract({ + address: poolAddress, + abi: ERC20ABI, + functionName: 'symbol', + }).catch(() => 'POOL'), + publicClient.readContract({ + address: poolAddress, + abi: IPartyPoolABI, + functionName: 'allTokens', + }).catch(() => [] as readonly `0x${string}`[]), + ]); + + details.push({ + address: poolAddress, + name: name as string, + symbol: symbol as string, + tokens: tokens as readonly `0x${string}`[], + }); + } catch (err) { + console.error('Error fetching pool details for', poolAddress, err); + // Add pool with fallback values + details.push({ + address: poolAddress, + name: 'Unknown Pool', + symbol: 'POOL', + tokens: [], + }); + } + } + + setPoolDetails(details); + } catch (err) { + console.error('Error calling getAllPools:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch pools'); + } finally { + setLoading(false); + } + }; + + fetchPools(); + }, [publicClient, mounted, offset, limit]); + + return { + pools, + poolDetails, + loading, + error, + isReady: mounted, + }; +} + +export interface SwapMintAmounts { + amountInUsed: bigint; + fee: bigint; + lpMinted: bigint; +} + +export function useSwapMintAmounts( + poolAddress: `0x${string}` | undefined, + inputTokenIndex: number | undefined, + maxAmountIn: bigint | undefined +) { + const publicClient = usePublicClient(); + const [mounted, setMounted] = useState(false); + const [swapMintAmounts, setSwapMintAmounts] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Handle hydration for Next.js static export + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted || !poolAddress || inputTokenIndex === undefined || !maxAmountIn || maxAmountIn === BigInt(0)) { + setLoading(false); + setSwapMintAmounts(null); + return; + } + + const fetchSwapMintAmounts = async () => { + if (!publicClient) { + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + // Get chain ID and contract address + const chainId = await publicClient.getChainId(); + const viewerAddress = (chainInfo as Record)[chainId.toString()]?.v1?.PartyPoolViewer; + + if (!viewerAddress) { + setError('IPartyPoolViewer contract not found for current chain'); + setSwapMintAmounts(null); + return; + } + + // Call swapMintAmounts function + const result = await publicClient.readContract({ + address: viewerAddress as `0x${string}`, + abi: IPartyPoolViewerABI, + functionName: 'swapMintAmounts', + args: [poolAddress, BigInt(inputTokenIndex), maxAmountIn], + }) as readonly [bigint, bigint, bigint]; + + setSwapMintAmounts({ + amountInUsed: result[0], + fee: result[1], + lpMinted: result[2], + }); + } catch (err) { + console.error('Error calling swapMintAmounts:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch swap mint amounts'); + setSwapMintAmounts(null); + } finally { + setLoading(false); + } + }; + + fetchSwapMintAmounts(); + }, [publicClient, mounted, poolAddress, inputTokenIndex, maxAmountIn]); + + return { + swapMintAmounts, + loading, + error, + isReady: mounted, + }; } \ No newline at end of file diff --git a/src/hooks/usePartyPool.ts b/src/hooks/usePartyPool.ts index 5eea981..d706099 100644 --- a/src/hooks/usePartyPool.ts +++ b/src/hooks/usePartyPool.ts @@ -357,4 +357,109 @@ export function useSwap() { gasEstimate, isEstimatingGas, }; +} + +export function useSwapMint() { + const publicClient = usePublicClient(); + const { data: walletClient } = useWalletClient(); + const [isSwapMinting, setIsSwapMinting] = useState(false); + const [swapMintHash, setSwapMintHash] = useState<`0x${string}` | null>(null); + const [swapMintError, setSwapMintError] = useState(null); + + const executeSwapMint = async ( + poolAddress: `0x${string}`, + inputTokenAddress: `0x${string}`, + inputTokenIndex: number, + maxAmountIn: bigint + ) => { + if (!walletClient || !publicClient) { + setSwapMintError('Wallet not connected'); + return; + } + + try { + setIsSwapMinting(true); + setSwapMintError(null); + setSwapMintHash(null); + + const userAddress = walletClient.account.address; + + // STEP 1: Approve the pool to spend the input token + console.log('🔐 Approving token spend for swap mint...'); + console.log('Token to approve:', inputTokenAddress); + console.log('Spender (pool):', poolAddress); + console.log('Amount:', maxAmountIn.toString()); + + const approvalHash = await walletClient.writeContract({ + address: inputTokenAddress, + abi: [ + { + name: 'approve', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' } + ], + outputs: [{ name: '', type: 'bool' }] + } + ], + functionName: 'approve', + args: [poolAddress, maxAmountIn], + }); + + console.log('✅ Approval transaction submitted:', approvalHash); + await publicClient.waitForTransactionReceipt({ hash: approvalHash }); + console.log('✅ Approval confirmed'); + + // STEP 2: Calculate deadline (5 minutes from now) + const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); // 5 minutes = 300 seconds + + console.log('🚀 Executing swapMint with params:', { + pool: poolAddress, + payer: userAddress, + receiver: userAddress, + inputTokenIndex, + maxAmountIn: maxAmountIn.toString(), + deadline: deadline.toString(), + }); + + // STEP 3: Execute the swapMint transaction + const hash = await walletClient.writeContract({ + address: poolAddress, + abi: IPartyPoolABI, + functionName: 'swapMint', + args: [ + userAddress, // payer + userAddress, // receiver + BigInt(inputTokenIndex), + maxAmountIn, + deadline, + ], + }); + + setSwapMintHash(hash); + console.log('✅ SwapMint transaction submitted:', hash); + + // Wait for transaction confirmation + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + console.log('✅ SwapMint transaction confirmed:', receipt); + + return receipt; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'SwapMint failed'; + setSwapMintError(errorMessage); + console.error('❌ SwapMint error:', err); + throw err; + } finally { + setIsSwapMinting(false); + } + }; + + return { + executeSwapMint, + isSwapMinting, + swapMintHash, + swapMintError, + }; } \ No newline at end of file diff --git a/src/locales/en.json b/src/locales/en.json index ffe3273..97323ab 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -20,6 +20,14 @@ }, "stake": { "title": "Stake", - "comingSoon": "Coming soon..." + "comingSoon": "Coming soon...", + "selectPool": "Select Pool", + "selectToken": "Select Token", + "amount": "Amount", + "stakeButton": "Stake", + "insufficientBalance": "Insufficient balance", + "amountUsed": "Amount Used", + "fee": "Fee", + "lpMinted": "LP Minted" } }