{ + if (!isSwapConfigured()) return null; + if (!/^\d+(\.\d+)?$/.test(amountInHuman) || Number(amountInHuman) <= 0) return null; + + const provider = new JsonRpcProvider(MAKALU_RPC); + const router = new Contract(SWAP_ROUTER, ROUTER_ABI, provider); + const amountIn = parseUnits(amountInHuman, decimalsIn); + const path = buildPath(tokenIn, tokenOut); + const amounts = (await router.getAmountsOut(amountIn, path)) as bigint[]; + return { amountOut: amounts[amounts.length - 1], path, multiHop: path.length > 2 }; +} + +/** Apply a slippage tolerance (in basis points) to a quoted output. */ +export function minOut(amountOut: bigint, slippageBps: number): bigint { + return (amountOut * BigInt(10_000 - slippageBps)) / BigInt(10_000); +} + +/** Ensure the router can pull `amount` of `token` from the user. */ +export async function ensureAllowance( + walletProvider: Eip1193Provider, + token: string, + amount: bigint, +): Promise{ + return approveIfNeeded(walletProvider, token, SWAP_ROUTER, amount); +} + +/** + * Execute the swap through the connected wallet. `amountOutMin` is the + * slippage-guarded floor; the tx reverts on-chain if the pool can't deliver it. + */ +export async function swapExactTokensForTokens( + walletProvider: Eip1193Provider, + tokenIn: string, + tokenOut: string, + amountInHuman: string, + decimalsIn: number, + amountOutMin: bigint, + to: string, + deadlineSecondsFromNow = 1800, +): Promise { + const signer = await new BrowserProvider(walletProvider).getSigner(); + const router = new Contract(SWAP_ROUTER, ROUTER_ABI, signer); + const amountIn = parseUnits(amountInHuman, decimalsIn); + const path = buildPath(tokenIn, tokenOut); + const deadline = Math.floor(Date.now() / 1000) + deadlineSecondsFromNow; + + const tx = await router.swapExactTokensForTokens(amountIn, amountOutMin, path, to, deadline); + const receipt = await tx.wait(); + if (!receipt || receipt.status !== 1) throw new Error('Swap transaction failed'); + return tx.hash; +} + +export function formatAmount(value: bigint, decimals: number, maxFrac = 6): string { + const s = formatUnits(value, decimals); + const [int, frac = ''] = s.split('.'); + return frac ? `${int}.${frac.slice(0, maxFrac)}`.replace(/\.?0+$/, '') || int : int; +} + +/** Compact, actionable message for the common swap failures. */ +export function describeSwapError(err: unknown, symbolOut: string): string { + const e = err as { code?: number | string; shortMessage?: string; reason?: string; message?: string } | null; + const msg = [e?.shortMessage, e?.reason, e?.message].filter((v) => typeof v === 'string').join(' | '); + + if (e?.code === 'ACTION_REJECTED' || /user denied|user rejected|4001/i.test(msg)) { + return 'Swap cancelled in the wallet — nothing was sent.'; + } + if (/INSUFFICIENT_OUTPUT_AMOUNT/i.test(msg)) { + return `Price moved beyond your slippage tolerance — you'd receive less ${symbolOut} than the minimum. Raise slippage or retry.`; + } + if (/INSUFFICIENT_LIQUIDITY|PAIR_NOT_FOUND/i.test(msg)) { + return 'This pair has no liquidity pool yet on Lithoswap. Try routing through wLITHO or pick another token.'; + } + if (/EXPIRED/i.test(msg)) { + return 'The swap deadline passed before it confirmed. Retry.'; + } + if (/insufficient funds/i.test(msg)) { + return 'Not enough native LITHO to pay gas — top up from the faucet and retry.'; + } + if (/transfer amount exceeds balance|insufficient balance/i.test(msg)) { + return 'Insufficient token balance for this swap.'; + } + const trimmed = msg.replace(/\s+/g, ' ').trim(); + return trimmed ? trimmed.slice(0, 200) : 'Swap failed.'; +} diff --git a/Makalu/explorer/pages/swap.tsx b/Makalu/explorer/pages/swap.tsx new file mode 100644 index 0000000..b2ff380 --- /dev/null +++ b/Makalu/explorer/pages/swap.tsx @@ -0,0 +1,322 @@ +import Head from 'next/head'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useWeb3Modal, useWeb3ModalAccount, useWeb3ModalProvider } from '@web3modal/ethers/react'; +import { parseUnits, type Eip1193Provider } from 'ethers'; +import { EXPLORER_TITLE } from '@/lib/constants'; +import { + MAKALU_CHAIN_ID, + MAKALU_RPC, + SWAP_TOKENS, + type SwapToken, + type Quote, + describeSwapError, + ensureAllowance, + formatAmount, + getQuote, + isSwapConfigured, + minOut, + swapExactTokensForTokens, +} from '@/lib/swap'; + +const PRIMARY_CTA = + 'rounded-2xl border border-sky-300/20 bg-gradient-to-r from-[#1cc7ff] via-[#227dff] to-[#3157ff] px-5 py-3 text-sm font-medium text-white shadow-[0_18px_40px_rgba(37,99,235,0.35)] transition duration-200 hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0'; + +const SLIPPAGE_OPTIONS = [10, 50, 100]; // basis points: 0.1%, 0.5%, 1.0% + +const MAKALU_PARAMS = { + chainId: '0x' + MAKALU_CHAIN_ID.toString(16), + chainName: 'Lithosphere Makalu', + rpcUrls: [MAKALU_RPC], + nativeCurrency: { name: 'LITHO', symbol: 'LITHO', decimals: 18 }, + blockExplorerUrls: ['https://makalu.litho.ai'], +}; + +function SwapContent() { + const { open } = useWeb3Modal(); + const { isConnected, address } = useWeb3ModalAccount(); + const { walletProvider } = useWeb3ModalProvider(); + + const configured = isSwapConfigured(); + + const [inSymbol, setInSymbol] = useState(SWAP_TOKENS[0].symbol); + const [outSymbol, setOutSymbol] = useState(SWAP_TOKENS[2]?.symbol ?? SWAP_TOKENS[1].symbol); + const [amount, setAmount] = useState('1'); + const [slippageBps, setSlippageBps] = useState(50); + + const [quote, setQuote] = useState (null); + const [quoting, setQuoting] = useState(false); + const [quoteError, setQuoteError] = useState(''); + + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + const [statusType, setStatusType] = useState<'info' | 'error' | 'success'>('info'); + + const tokenIn = useMemo( + () => SWAP_TOKENS.find((t) => t.symbol === inSymbol) ?? SWAP_TOKENS[0], + [inSymbol], + ); + const tokenOut = useMemo( + () => SWAP_TOKENS.find((t) => t.symbol === outSymbol) ?? SWAP_TOKENS[1], + [outSymbol], + ); + const sameToken = tokenIn.address.toLowerCase() === tokenOut.address.toLowerCase(); + + function show(msg: string, type: 'info' | 'error' | 'success' = 'info') { + setStatus(msg); + setStatusType(type); + } + + function flip() { + setInSymbol(outSymbol); + setOutSymbol(inSymbol); + } + + // Debounced live quote whenever the inputs change. + useEffect(() => { + if (!configured || sameToken) { + setQuote(null); + setQuoteError(''); + return; + } + if (!/^\d+(\.\d+)?$/.test(amount) || Number(amount) <= 0) { + setQuote(null); + setQuoteError(''); + return; + } + let cancelled = false; + setQuoting(true); + setQuoteError(''); + const id = setTimeout(async () => { + try { + const q = await getQuote(amount, tokenIn.address, tokenIn.decimals, tokenOut.address); + if (!cancelled) setQuote(q); + } catch { + if (!cancelled) { + setQuote(null); + setQuoteError( + `No liquidity route for ${tokenIn.symbol} → ${tokenOut.symbol} yet. Once pools are seeded this will quote automatically.`, + ); + } + } finally { + if (!cancelled) setQuoting(false); + } + }, 400); + return () => { + cancelled = true; + clearTimeout(id); + }; + }, [amount, tokenIn, tokenOut, configured, sameToken]); + + const ensureMakalu = useCallback(async () => { + if (!walletProvider) throw new Error('Wallet not connected'); + const provider = walletProvider as Eip1193Provider; + const actual = async () => Number(await provider.request({ method: 'eth_chainId' })); + if ((await actual()) === MAKALU_CHAIN_ID) return; + try { + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: MAKALU_PARAMS.chainId }], + }); + } catch { + await provider.request({ method: 'wallet_addEthereumChain', params: [MAKALU_PARAMS] }); + } + for (let i = 0; i < 4; i++) { + if ((await actual()) === MAKALU_CHAIN_ID) return; + await new Promise((r) => setTimeout(r, 700)); + } + throw new Error(`Wallet is not on Lithosphere Makalu (${MAKALU_CHAIN_ID}). Switch manually and retry.`); + }, [walletProvider]); + + async function handleSwap() { + if (!isConnected || !walletProvider || !address) { + void open({ view: 'Connect' }); + return; + } + if (sameToken) { + show('Pick two different tokens.', 'error'); + return; + } + if (!quote) { + show('No quote available — enter an amount with an available route.', 'error'); + return; + } + setBusy(true); + try { + show('Switching wallet to Lithosphere Makalu…'); + await ensureMakalu(); + + const amountIn = parseUnits(amount, tokenIn.decimals); + show(`Approving ${tokenIn.symbol}…`); + await ensureAllowance(walletProvider as Eip1193Provider, tokenIn.address, amountIn); + + const floor = minOut(quote.amountOut, slippageBps); + show(`Swapping ${amount} ${tokenIn.symbol} → ${tokenOut.symbol}…`); + const hash = await swapExactTokensForTokens( + walletProvider as Eip1193Provider, + tokenIn.address, + tokenOut.address, + amount, + tokenIn.decimals, + floor, + address, + ); + show(`Swap confirmed: ${hash}`, 'success'); + } catch (err: unknown) { + show(describeSwapError(err, tokenOut.symbol), 'error'); + } finally { + setBusy(false); + } + } + + const statusColors = { + info: 'border-blue-400/20 bg-blue-400/10 text-blue-200', + error: 'border-red-400/20 bg-red-400/10 text-red-200', + success: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200', + }; + const selectCls = + 'w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white outline-none focus:border-sky-400/50'; + + const outDisplay = quote ? formatAmount(quote.amountOut, tokenOut.decimals) : '—'; + const minDisplay = quote ? formatAmount(minOut(quote.amountOut, slippageBps), tokenOut.decimals) : '—'; + + return ( + <> + +Swap | {EXPLORER_TITLE} + +++ > + ); +} + +export default function SwapPage() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return null; + return++++ + {!configured && ( ++ MultX Swap ++Swap tokens
++ Swap LEP-100 tokens on Lithosphere Makalu through the Lithoswap AMM. Quotes include the + 0.30% pool fee; swaps route through wLITHO when there is no direct pair. +
++ Lithoswap isn't live on Makalu yet. Once the router is deployed and pools are + seeded, swapping turns on here automatically — no app update needed. ++ )} + + {status && ( +{status}+ )} + ++ + ++ setAmount(e.target.value)} + inputMode="decimal" + placeholder="0.0" + className="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white outline-none placeholder:text-white/30 focus:border-sky-400/50" + /> + ++ ++ ++ + +++ ++ {quoting ? 'Quoting…' : outDisplay} ++ ++ Slippage tolerance ++ + {quote && ( ++ {SLIPPAGE_OPTIONS.map((bps) => ( + + ))} ++++ )} + + {sameToken && ( ++ Minimum received + {minDisplay} {tokenOut.symbol} +++ Route + {quote.multiHop ? `${tokenIn.symbol} → wLITHO → ${tokenOut.symbol}` : `${tokenIn.symbol} → ${tokenOut.symbol}`} ++Choose two different tokens to swap.
+ )} + {quoteError && !sameToken && ( ++ {quoteError} +
+ )} + ++ ++; +}