diff --git a/Makalu/explorer/components/Header.tsx b/Makalu/explorer/components/Header.tsx index 9956da3..2c9b2de 100644 --- a/Makalu/explorer/components/Header.tsx +++ b/Makalu/explorer/components/Header.tsx @@ -35,6 +35,7 @@ const NAV_ITEMS: NavItem[] = [ { label: 'Tokens', href: '/tokens' }, { label: 'NFTs', href: '/nfts' }, { label: 'Bridge', href: '/bridge' }, + { label: 'Swap', href: '/swap' }, { label: 'Faucet', href: '/faucet' }, { label: 'Sign In', href: '/signin' }, ]; @@ -519,7 +520,7 @@ function HeaderContent() { setWalletMenuOpen(false)} className="flex w-full items-center justify-between rounded-2xl bg-white/[0.02] px-4 py-3 text-left text-[15px] font-medium text-white/90 transition hover:bg-white/[0.06]" > diff --git a/Makalu/explorer/lib/swap.ts b/Makalu/explorer/lib/swap.ts new file mode 100644 index 0000000..090a205 --- /dev/null +++ b/Makalu/explorer/lib/swap.ts @@ -0,0 +1,171 @@ +/** + * Lithoswap V2 same-chain swap integration (Makalu 700777). + * + * Talks to the Lithoswap Router (a Uniswap V2 port deployed by + * Makalu/contracts/scripts/deploy-dex.ts) with ethers v6. Quotes are read-only + * via getAmountsOut against the public RPC; the swap itself goes through the + * connected wallet. WLITHO is the routing base — pairs are seeded token↔WLITHO, + * so a token→token swap routes token→WLITHO→token. + * + * The router address is injected at build time as NEXT_PUBLIC_SWAP_ROUTER once + * the DEX is deployed; until then `isSwapConfigured()` is false and the UI shows + * a "not yet live" state instead of calling a zero address. + */ +import { BrowserProvider, Contract, JsonRpcProvider, formatUnits, parseUnits } from 'ethers'; +import type { Eip1193Provider } from 'ethers'; +import { BRIDGE_TOKENS, approveIfNeeded } from '@/lib/bridge'; + +export const MAKALU_CHAIN_ID = 700777; +export const MAKALU_RPC = 'https://rpc.litho.ai'; + +/** WLITHO — the wrapped-native LEP-100 ERC-20, used as the routing base. */ +export const WLITHO_ADDRESS = '0x599a7E135f1790ae117b4EdDc0422D24Bc766161'; + +/** Router address, injected post-deploy. Empty until the DEX is live. */ +export const SWAP_ROUTER = (process.env.NEXT_PUBLIC_SWAP_ROUTER ?? '').trim(); + +const ZERO = '0x0000000000000000000000000000000000000000'; + +export function isSwapConfigured(): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(SWAP_ROUTER) && SWAP_ROUTER.toLowerCase() !== ZERO; +} + +export interface SwapToken { + symbol: string; + name: string; + address: string; + decimals: number; +} + +/** + * Swappable tokens on Makalu = the same LEP-100 set the bridge lists, using + * their Makalu-side addresses (wLITHO first — it is the routing base). + */ +export const SWAP_TOKENS: SwapToken[] = BRIDGE_TOKENS.map((t) => ({ + symbol: t.symbol, + name: t.name, + address: t.makalu, + decimals: t.decimals, +})); + +const ROUTER_ABI = [ + 'function factory() view returns (address)', + 'function getAmountsOut(uint256 amountIn, address[] path) view returns (uint256[] amounts)', + 'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) returns (uint256[] amounts)', +]; + +/** + * Route between two tokens. A direct pair is used when one side is WLITHO; + * otherwise the swap hops through WLITHO (the base every pool is seeded against). + */ +export function buildPath(tokenIn: string, tokenOut: string): string[] { + const wl = WLITHO_ADDRESS.toLowerCase(); + if (tokenIn.toLowerCase() === wl || tokenOut.toLowerCase() === wl) { + return [tokenIn, tokenOut]; + } + return [tokenIn, WLITHO_ADDRESS, tokenOut]; +} + +export interface Quote { + /** Raw output amount (wei of tokenOut). */ + amountOut: bigint; + /** Route taken. */ + path: string[]; + /** True when the hop goes token→WLITHO→token. */ + multiHop: boolean; +} + +/** + * Read-only quote via the router's getAmountsOut. Returns null if the DEX is + * not configured; throws (caller shows the message) when the route has no + * liquidity — getAmountsOut reverts in that case. + */ +export async function getQuote( + amountInHuman: string, + tokenIn: string, + decimalsIn: number, + tokenOut: string, +): Promise { + 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} + +
+
+
+
+ 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. +

+
+ + {!configured && ( +
+ 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 +
+ {SLIPPAGE_OPTIONS.map((bps) => ( + + ))} +
+
+ + {quote && ( +
+
+ Minimum received + {minDisplay} {tokenOut.symbol} +
+
+ Route + {quote.multiHop ? `${tokenIn.symbol} → wLITHO → ${tokenOut.symbol}` : `${tokenIn.symbol} → ${tokenOut.symbol}`} +
+
+ )} + + {sameToken && ( +

Choose two different tokens to swap.

+ )} + {quoteError && !sameToken && ( +

+ {quoteError} +

+ )} + +
+ +
+
+
+
+ + ); +} + +export default function SwapPage() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + if (!mounted) return null; + return ; +}