// ZAP panel — fund a position with ONE token: a pool-side token, WETH/USDG, // or ANY token (custom picker). Shared by POOLS (mint/add) and POSITIONS // (increase): the parent picks the target, this panel solves the split, // previews it, and walks the execution sequence. See lib/zap.ts for the math. import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import { useAccount, usePublicClient } from 'wagmi' import { useQuery, useQueryClient } from '@tanstack/react-query' import { formatUnits, parseUnits, type Address } from 'viem' import { ADDR, CHAIN_ID, NATIVE } from '../config/addresses' import { ENV } from '../config/env' import { simulateClAdd, simulateV2Add, fmtApr } from '../lib/apr' import { applySlippage } from '../lib/clmath' import { fmtAmount, fmtUsd } from '../lib/format' import { autoSlippage, retrySlippage, slippagePctToBps, slippageTone, SLIPPAGE_CHOICES, type AutoSlippage } from '../lib/swapGate' import { SOLVER_AUTO_REFRESHES, solverQuoteAutoRefreshExhausted, solverQuoteCanAutoRefresh } from '../lib/solverRefresh' import { autostake } from '../lib/autostake' import { DEPOSIT_MINS_BPS, executeZap, planZap, stakeableTarget, zapRouteLabel, zapStages, zapVenueFeeBps, type ZapFailReason, type ZapPlan, type ZapTarget, } from '../lib/zap' import { useBalances } from '../hooks/useBalances' import { usePools } from '../hooks/usePools' import { useTokenList } from '../hooks/useTokenList' import type { PoolStat } from '../lib/poolstats' import type { TokenInfo } from '../types' import { StakeAfterToggle } from './StakeAfterToggle' import { TokenSelect } from './TokenSelect' import { Btn, NumInput } from './ui' const ETH_GAS_BUFFER = parseUnits('0.001', 18) const ZAP_PLAN_REFRESH_MS = 30_000 const ETH_TOKEN: TokenInfo = { address: NATIVE as Address, symbol: 'ETH', decimals: 18, native: true } const WETH_TOKEN: TokenInfo = { address: ADDR.WETH, symbol: 'WETH', decimals: 18 } const USDG_TOKEN: TokenInfo = { address: ADDR.USDG, symbol: 'USDG', decimals: 6 } export function ZapPanel(props: { target: ZapTarget t0: TokenInfo t1: TokenInfo stat?: PoolStat upUsd?: number wethUsd?: number | null }) { const { target, t0, t1 } = props const { t } = useTranslation() const pool = target.pool const { address: user } = useAccount() const client = usePublicClient({ chainId: CHAIN_ID }) const pools = usePools() const tokenList = useTokenList() const autoStake = useSyncExternalStore(autostake.subscribe, autostake.get) const stakeable = stakeableTarget(target) // funding candidates: the pair itself, ETH, and the two majors (deduped — // a WETH/USDG pool doesn't repeat them); anything else via the picker const candidates = useMemo(() => { const seen = new Set() return [t0, t1, ETH_TOKEN, WETH_TOKEN, USDG_TOKEN].filter((tok) => { const k = tok.address.toLowerCase() if (seen.has(k)) return false seen.add(k) return true }) }, [t0, t1]) const [selTok, setSelTok] = useState(null) const tIn = selTok ?? t0 const tokenInAddr: Address = tIn.address const customSel = !candidates.some((c) => c.address.toLowerCase() === tokenInAddr.toLowerCase()) const [amtStr, setAmtStr] = useState('') const [amount, setAmount] = useState(0n) // slippage: null = AUTO (quote-derived); a bps number = a preset chip or the // typed custom value (customSlip holds the raw text so the input can own focus) const [manualSlip, setManualSlip] = useState(null) const [customSlip, setCustomSlip] = useState('') const [slipOpen, setSlipOpen] = useState(false) // floor under AUTO after a slippage halt: never re-offer the number that // just failed. Cleared when the trade context changes or a run succeeds. const [slipFloor, setSlipFloor] = useState(null) const [running, setRunning] = useState(false) const [runAt, setRunAt] = useState<{ i: number; failed: boolean; reason?: ZapFailReason; slip?: number } | null>( null, ) const [runPlan, setRunPlan] = useState(null) const [done, setDone] = useState(false) useEffect(() => { const h = setTimeout(() => { try { setAmount(parseUnits(amtStr === '' ? '0' : amtStr, tIn.decimals)) } catch { setAmount(0n) } }, 350) return () => clearTimeout(h) }, [amtStr, tIn.decimals]) // a new pool/token/amount is a new trade — yesterday's failure floor doesn't apply useEffect(() => { setSlipFloor(null) }, [pool.address, tokenInAddr, amount]) const balAddrs = useMemo(() => { const s = new Map() for (const c of candidates) s.set(c.address.toLowerCase(), c.address) s.set(tokenInAddr.toLowerCase(), tokenInAddr) return [...s.values()] }, [candidates, tokenInAddr]) const bal = useBalances(user, balAddrs) const balIn = bal.data?.[tokenInAddr.toLowerCase()] let spendable = balIn if (balIn !== undefined && tIn.native) spendable = balIn > ETH_GAS_BUFFER ? balIn - ETH_GAS_BUFFER : 0n const insufficient = spendable !== undefined && amount > spendable // ticks key parts (cl targets re-plan when the parent's range changes) const lo = target.kind === 'v2' ? 0 : target.tickLower const hi = target.kind === 'v2' ? 0 : target.tickUpper const up33State = pools.error ? 'up33-failed' : pools.data ? 'up33-ready' : 'up33-pending' const queryClient = useQueryClient() const zapPlanKey = ['zapPlan', pool.address, lo, hi, tokenInAddr, amount.toString(), up33State] as const const plan = useQuery({ queryKey: zapPlanKey, // same cap as the swap's solver quote: auto-refresh a few times, then pause // — execution re-quotes the swap leg fresh regardless, and the manual // refresh below revives a paused (or failed-out) preview enabled: (query) => !!client && !pools.isPending && amount > 0n && !running && solverQuoteCanAutoRefresh(query.state), refetchInterval: ZAP_PLAN_REFRESH_MS, staleTime: 15_000, retry: false, queryFn: () => planZap({ client: client!, pools: pools.error ? null : (pools.data?.pools ?? null), target, tokenIn: tokenInAddr, amountIn: amount, }), }) const p = running ? runPlan : (plan.data ?? null) // once the auto-refresh cap is hit the preview stops updating — surface a // manual refresh. Gate on amount, not on data: four planning FAILURES also // exhaust the budget, and that is exactly when a retry control is needed. const planRecord = queryClient.getQueryCache().find({ queryKey: zapPlanKey, exact: true }) const planPaused = amount > 0n && !running && !!planRecord && solverQuoteAutoRefreshExhausted(planRecord.state) const autoBase = p?.impactBps == null ? null : autoSlippage(p.impactBps, zapVenueFeeBps(p)) // post-halt floor wins over the quote-derived number (and stands in for it // when the impact probe is unavailable — a failed run IS a measurement) const flooredBps = slipFloor === null ? null : Math.max(autoBase?.bps ?? 0, slipFloor) const automaticSlippage: AutoSlippage | null = flooredBps === null ? autoBase : { bps: flooredBps, tone: slippageTone(flooredBps) } const effectiveSlip = manualSlip ?? automaticSlippage?.bps const customInvalid = customSlip !== '' && slippagePctToBps(customSlip) === null const slippageRequired = !!p && p.legs.length > 0 && effectiveSlip === undefined const stages = useMemo( () => (p ? zapStages(p, target, tIn, t0, t1, autoStake) : []), [p, target, tIn, t0, t1, autoStake], ) // projected APRs on the planned deposit (mint/add previews only — an // increase's projection is the parent card's business) const sim = useMemo(() => { if (!p || target.kind === 'cl-increase') return null const a0h = Number(formatUnits(p.dep0, t0.decimals)) const a1h = Number(formatUnits(p.dep1, t1.decimals)) if (target.kind === 'cl-mint') return simulateClAdd({ pool: target.pool, tickLower: target.tickLower, tickUpper: target.tickUpper, liquidity: p.liquidity, amount0h: a0h, amount1h: a1h, dec0: t0.decimals, dec1: t1.decimals, stat: props.stat, upUsd: props.upUsd, wethUsd: props.wethUsd, }) return simulateV2Add({ pool: target.pool, amount0h: a0h, amount1h: a1h, dec0: t0.decimals, dec1: t1.decimals, stat: props.stat, upUsd: props.upUsd, }) }, [p, target, t0, t1, props.stat, props.upUsd, props.wethUsd]) // impact-vs-band honesty: a swap that eats a big slice of the band's width // will land the deposit off-ratio (dust) or out of band entirely const bandWarn = useMemo(() => { if (!p || p.impactBps === null || target.kind === 'v2') return null const halfPct = (Math.pow(1.0001, (target.tickUpper - target.tickLower) / 2) - 1) * 100 const impactPct = p.impactBps / 100 if (impactPct > Math.max(halfPct / 3, 2)) return t('zap.bandWarn', { impact: impactPct.toFixed(2), band: halfPct.toFixed(1) }) return null }, [p, target, t]) const dustNote = (side: 0 | 1): string | null => { if (!p) return null const dust = side === 0 ? p.dust0 : p.dust1 const dep = side === 0 ? p.dep0 : p.dep1 if (dust === 0n || dep === 0n) return null if (dust * 200n < dep) return null // <0.5% — noise const token = side === 0 ? t0 : t1 return `${fmtAmount(dust, token.decimals)} ${token.symbol}` } const dusts = [dustNote(0), dustNote(1)].filter(Boolean) const run = async () => { const frozen = plan.data if (!user || !frozen || amount === 0n || running) return const executionSlippage = frozen.legs.length === 0 ? 0 : effectiveSlip if (executionSlippage === undefined) return setRunPlan(frozen) setRunning(true) setDone(false) setRunAt({ i: 0, failed: false }) try { const res = await executeZap({ plan: frozen, target, user, slipBps: executionSlippage, stake: autoStake, tIn, t0, t1, onStage: (i) => setRunAt({ i, failed: false }), }) if (res.ok) { setRunAt(null) setRunPlan(null) setDone(true) setSlipFloor(null) setAmtStr('') } else { const reason = res.reason ?? 'other' setRunAt({ i: res.failedAt ?? 0, failed: true, reason, slip: executionSlippage }) // AUTO must not re-offer the tolerance that just failed — only ever raise if (reason === 'slippage') setSlipFloor((prev) => Math.max(prev ?? 0, retrySlippage(executionSlippage))) // and the retry must re-plan from a live quote, not the ≤30s cached one void plan.refetch() } } finally { setRunning(false) } } const failed = runAt?.failed ?? false let runLabel = t('zap.run') if (!user) runLabel = t('common.connectWallet') else if (slippageRequired) runLabel = t('zap.chooseSlippage') else if (stages.length > 0) runLabel = t('zap.runSteps', { n: stages.length }) // slippage: one summary line; the full editor unfolds on demand (or when a // choice is REQUIRED — no impact probe / invalid custom value) const slipForced = slippageRequired || customInvalid const showSlipEditor = slipOpen || slipForced const slipSummaryTone = customInvalid ? 'red' : effectiveSlip !== undefined ? slippageTone(effectiveSlip) : 'dim' const slipSummary = customInvalid ? t('common.slipInvalid') : effectiveSlip !== undefined ? `${manualSlip === null && customSlip === '' ? 'AUTO · ' : ''}${effectiveSlip / 100}%` : t('zap.chooseSlippage') const totalSwapIn = p ? p.legs.reduce((s, l) => s + l.swapIn, 0n) : 0n const legRows = p ? p.legs.map((leg, i) => { const tOut = leg.buyIs0 ? t0 : t1 return (
{p.legs.length > 1 ? t('zap.swapN', { n: i + 1 }) : t('zap.swapRow')} {fmtAmount(leg.swapIn, tIn.decimals)} {tIn.symbol} → ≈ {fmtAmount(leg.estOut, tOut.decimals)}{' '} {tOut.symbol} {effectiveSlip === undefined ? t('zap.chooseSlippage') : t('zap.swapMin', { amt: fmtAmount(applySlippage(leg.estOut, effectiveSlip), tOut.decimals), slip: effectiveSlip / 100, })} {leg.impactBps === null ? ( · {t('zap.impactOff')} ) : ( {' '} · {t('zap.impact', { pct: (leg.impactBps / 100).toFixed(2) })} )} {/* zap still charges while market swaps are free — keep it bright */} · {t('zap.terminalFee', { pct: (ENV.zapFeeBps / 100).toFixed(2) })} {' · '} {t('zap.via', { route: zapRouteLabel(leg.via) })}
) }) : null return (
{t('zap.zapIn')} {candidates.map((c) => ( ))} {customSel && }
{spendable !== undefined && ( <> {t('common.bal')} {fmtAmount(spendable, tIn.decimals)} {tIn.symbol} )} {insufficient && {t('common.exceedsBalance')}}
{t('zap.slip')} {!showSlipEditor ? ( ) : ( <> {SLIPPAGE_CHOICES.map((b) => ( ))} { setCustomSlip(v) setManualSlip(slippagePctToBps(v)) }} disabled={running} placeholder={t('common.slipCustom')} width={96} invalid={customInvalid} /> % {customInvalid && {t('common.slipInvalid')}} {effectiveSlip !== undefined && ( → {effectiveSlip / 100}% )} {!slipForced && ( )} )}
{amount > 0n && plan.isLoading && (
{t('zap.solving')}
)} {amount > 0n && plan.isError && !running && (
{t('zap.cantPlan', { err: (plan.error as Error).message })}
)} {p && (
{t('zap.planTitle')}
{t('zap.split')} {p.inIs0 !== null ? p.legs.length === 0 ? t('zap.keepAll', { amt: fmtAmount(p.keep, tIn.decimals), sym: tIn.symbol }) : t('zap.keepSwap', { keep: fmtAmount(p.keep, tIn.decimals), swap: fmtAmount(totalSwapIn, tIn.decimals), sym: tIn.symbol, }) : p.legs.length === 2 ? t('zap.outsideSplit', { a0: fmtAmount(p.legs.find((l) => l.buyIs0)?.swapIn ?? 0n, tIn.decimals), a1: fmtAmount(p.legs.find((l) => !l.buyIs0)?.swapIn ?? 0n, tIn.decimals), sym: tIn.symbol, sym0: t0.symbol, sym1: t1.symbol, }) : t('zap.outsideOne', { amt: fmtAmount(totalSwapIn, tIn.decimals), sym: tIn.symbol, outSym: p.legs[0]?.buyIs0 ? t0.symbol : t1.symbol, })} {p.inIs0 !== null ? p.legs.length === 0 ? t('zap.splitSdSingle') : t('zap.splitSd') : t('zap.outsideSd')}
{legRows}
{t('zap.depositRow')} ≈ {fmtAmount(p.dep0, t0.decimals)} {t0.symbol} + {fmtAmount(p.dep1, t1.decimals)} {t1.symbol} {dusts.length > 0 ? t('zap.dustSd', { dust: dusts.join(' + ') }) : t('zap.actualSd')}
{sim && (
{t('add.projected')} {sim.inRange ? ( <> {t('add.projDep', { usd: fmtUsd(sim.depositUsd) })} {Number.isFinite(sim.feeApr) && ( <> {' '} · {t('add.projFeeApr')} {pool.protocol === 'up33' ? {t('add.projIfUnstaked')} : ''} ≈{' '} {fmtApr(sim.feeApr)} )} {Number.isFinite(sim.emitApr) && ( <> {' '} · {t('add.projEmitApr')} {t('add.projIfStaked')} ≈{' '} {fmtApr(sim.emitApr)} )} {t('add.projShare', { pct: sim.sharePct < 0.01 ? '<0.01' : sim.sharePct.toFixed(2) })} ) : ( {t('add.projOut')} )}
)} {bandWarn && (
!! {bandWarn}
)}
)} {planPaused && (
)} {stages.length > 0 && (
{stages.map((s, i) => { let state: 'todo' | 'done' | 'fail' | 'run' = 'todo' if (runAt && i < runAt.i) state = 'done' else if (runAt && i === runAt.i && failed) state = 'fail' else if (runAt && i === runAt.i && running) state = 'run' let mark = '·' if (state === 'done') mark = '✓' else if (state === 'run') mark = '▮' else if (state === 'fail') mark = '✗' return (
{i + 1} {mark} {s.label}
) })}
)} {failed && (
{runAt?.reason === 'slippage' ? t('zap.haltedSwap', { n: (runAt.i ?? 0) + 1, slip: (runAt.slip ?? 0) / 100, next: (automaticSlippage?.bps ?? retrySlippage(runAt.slip ?? 0)) / 100, }) : runAt?.reason === 'deposit-moved' ? t('zap.haltedDeposit', { n: (runAt.i ?? 0) + 1, band: DEPOSIT_MINS_BPS / 100 }) : t('zap.halted', { n: (runAt?.i ?? 0) + 1 })}
)} {done &&
{t('zap.done')}
}
{runLabel} {stakeable && } {t('zap.runHint')}
) }