mirror of
https://github.com/labrinyang/lp-terminal.git
synced 2026-07-27 13:17:43 +00:00
bca538e7e3
One commit because the pieces do not compile apart: the shared copy, the tab shell and the data layer all changed together, and splitting them further would mean inventing intermediate states that never existed. SHEEP CHOICE — the terminal's own swap. Quotes come from a solver that splits one trade across several pools instead of forcing it down a single path, and returns a ready-to-sign Settler transaction; the UI draws the split leg by leg and scores every venue against one shared fee-free baseline, so the card that says it pays most actually does. The Kyber transaction path is gone — Kyber is read-only USD valuation now, and there is no Kyber calldata to sign. BRIDGE — deposits from other chains over Relay, Across and the native portal, priced side by side and sorted by what actually reaches you. No fee on any of them. In-flight transfers get a countdown and survive a reload. ZAP — add liquidity holding neither side of the pair; whatever needs swapping is done in the same flow, with an optional stake-after step. Uniswap V2 liquidity now shows up under POSITIONS. Swaps in flight are persisted, so a refresh mid-swap no longer loses the transaction. Pair labels copy their token and pool addresses, and jump to DexScreener. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
575 lines
22 KiB
TypeScript
575 lines
22 KiB
TypeScript
// 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<string>()
|
|
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<TokenInfo | null>(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<number | null>(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<number | null>(null)
|
|
const [running, setRunning] = useState(false)
|
|
const [runAt, setRunAt] = useState<{ i: number; failed: boolean; reason?: ZapFailReason; slip?: number } | null>(
|
|
null,
|
|
)
|
|
const [runPlan, setRunPlan] = useState<ZapPlan | null>(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<string, Address>()
|
|
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 (
|
|
<div className="spec-row" key={i}>
|
|
<span className="sk">{p.legs.length > 1 ? t('zap.swapN', { n: i + 1 }) : t('zap.swapRow')}</span>
|
|
<span className="sv">
|
|
{fmtAmount(leg.swapIn, tIn.decimals)} {tIn.symbol} → ≈ {fmtAmount(leg.estOut, tOut.decimals)}{' '}
|
|
{tOut.symbol}
|
|
</span>
|
|
<span className="sd">
|
|
{effectiveSlip === undefined
|
|
? t('zap.chooseSlippage')
|
|
: t('zap.swapMin', {
|
|
amt: fmtAmount(applySlippage(leg.estOut, effectiveSlip), tOut.decimals),
|
|
slip: effectiveSlip / 100,
|
|
})}
|
|
{leg.impactBps === null ? (
|
|
<span className="dim"> · {t('zap.impactOff')}</span>
|
|
) : (
|
|
<span className={slippageTone(leg.impactBps)}>
|
|
{' '}
|
|
· {t('zap.impact', { pct: (leg.impactBps / 100).toFixed(2) })}
|
|
</span>
|
|
)}
|
|
{/* zap still charges while market swaps are free — keep it bright */}
|
|
<span className="fg"> · {t('zap.terminalFee', { pct: (ENV.zapFeeBps / 100).toFixed(2) })}</span>
|
|
{' · '}
|
|
{t('zap.via', { route: zapRouteLabel(leg.via) })}
|
|
</span>
|
|
</div>
|
|
)
|
|
})
|
|
: null
|
|
|
|
return (
|
|
<div className="zap">
|
|
<div className="form-row">
|
|
<span className="lbl">{t('zap.zapIn')}</span>
|
|
{candidates.map((c) => (
|
|
<button
|
|
key={c.address}
|
|
className={`chip ${tokenInAddr.toLowerCase() === c.address.toLowerCase() ? 'on' : ''}`}
|
|
onClick={() => setSelTok(c)}
|
|
disabled={running}
|
|
title={c.native ? t('zap.ethTip') : undefined}
|
|
>
|
|
{c.symbol}
|
|
</button>
|
|
))}
|
|
{customSel && <button className="chip on">{tIn.symbol}</button>}
|
|
<TokenSelect list={tokenList.tokens} value={tIn} onChange={setSelTok} label={t('zap.pickOther')} />
|
|
</div>
|
|
<div className="form-row">
|
|
<NumInput value={amtStr} onChange={setAmtStr} disabled={running} width={220} />
|
|
{spendable !== undefined && (
|
|
<>
|
|
<button className="chip" disabled={running} onClick={() => setAmtStr(formatUnits(spendable, tIn.decimals))}>
|
|
{t('common.max')}
|
|
</button>
|
|
<span className="dim mono-sm">
|
|
{t('common.bal')} {fmtAmount(spendable, tIn.decimals)} {tIn.symbol}
|
|
</span>
|
|
</>
|
|
)}
|
|
{insufficient && <span className="red mono-sm">{t('common.exceedsBalance')}</span>}
|
|
</div>
|
|
<div className="form-row">
|
|
<span className="lbl">{t('zap.slip')}</span>
|
|
{!showSlipEditor ? (
|
|
<button
|
|
className={`chip ${slipSummaryTone}`}
|
|
onClick={() => setSlipOpen(true)}
|
|
disabled={running}
|
|
title={t('zap.slipHint')}
|
|
>
|
|
{slipSummary} ▾
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
className={`chip ${manualSlip === null && customSlip === '' ? 'on' : ''}`}
|
|
onClick={() => {
|
|
setManualSlip(null)
|
|
setCustomSlip('')
|
|
}}
|
|
disabled={running}
|
|
>
|
|
AUTO {automaticSlippage ? `${automaticSlippage.bps / 100}%` : '—'}
|
|
</button>
|
|
{SLIPPAGE_CHOICES.map((b) => (
|
|
<button
|
|
key={b}
|
|
className={`chip ${manualSlip === b && customSlip === '' ? 'on' : ''}`}
|
|
onClick={() => {
|
|
setManualSlip(b)
|
|
setCustomSlip('')
|
|
}}
|
|
disabled={running}
|
|
>
|
|
{b / 100}%
|
|
</button>
|
|
))}
|
|
<NumInput
|
|
value={customSlip}
|
|
onChange={(v) => {
|
|
setCustomSlip(v)
|
|
setManualSlip(slippagePctToBps(v))
|
|
}}
|
|
disabled={running}
|
|
placeholder={t('common.slipCustom')}
|
|
width={96}
|
|
invalid={customInvalid}
|
|
/>
|
|
<span className="dim mono-sm">%</span>
|
|
{customInvalid && <span className="red mono-sm">{t('common.slipInvalid')}</span>}
|
|
{effectiveSlip !== undefined && (
|
|
<span className={`${slippageTone(effectiveSlip)} mono-sm`}>→ {effectiveSlip / 100}%</span>
|
|
)}
|
|
{!slipForced && (
|
|
<button className="chip" onClick={() => setSlipOpen(false)} disabled={running} title={t('zap.slipHint')}>
|
|
▴
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{amount > 0n && plan.isLoading && (
|
|
<div className="dim mono-sm">
|
|
{t('zap.solving')}
|
|
<span className="spin">▮</span>
|
|
</div>
|
|
)}
|
|
{amount > 0n && plan.isError && !running && (
|
|
<div className="red mono-sm">{t('zap.cantPlan', { err: (plan.error as Error).message })}</div>
|
|
)}
|
|
|
|
{p && (
|
|
<div className="spec">
|
|
<div className="spec-hd">{t('zap.planTitle')}</div>
|
|
<div className="spec-row">
|
|
<span className="sk">{t('zap.split')}</span>
|
|
<span className="sv">
|
|
{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,
|
|
})}
|
|
</span>
|
|
<span className="sd">
|
|
{p.inIs0 !== null
|
|
? p.legs.length === 0
|
|
? t('zap.splitSdSingle')
|
|
: t('zap.splitSd')
|
|
: t('zap.outsideSd')}
|
|
</span>
|
|
</div>
|
|
{legRows}
|
|
<div className="spec-row">
|
|
<span className="sk">{t('zap.depositRow')}</span>
|
|
<span className="sv">
|
|
≈ {fmtAmount(p.dep0, t0.decimals)} {t0.symbol} + {fmtAmount(p.dep1, t1.decimals)} {t1.symbol}
|
|
</span>
|
|
<span className="sd">
|
|
{dusts.length > 0 ? t('zap.dustSd', { dust: dusts.join(' + ') }) : t('zap.actualSd')}
|
|
</span>
|
|
</div>
|
|
{sim && (
|
|
<div className="spec-row">
|
|
<span className="sk">{t('add.projected')}</span>
|
|
{sim.inRange ? (
|
|
<>
|
|
<span className="sv">
|
|
{t('add.projDep', { usd: fmtUsd(sim.depositUsd) })}
|
|
{Number.isFinite(sim.feeApr) && (
|
|
<>
|
|
{' '}
|
|
· {t('add.projFeeApr')}
|
|
{pool.protocol === 'up33' ? <span className="dim"> {t('add.projIfUnstaked')}</span> : ''} ≈{' '}
|
|
{fmtApr(sim.feeApr)}
|
|
</>
|
|
)}
|
|
{Number.isFinite(sim.emitApr) && (
|
|
<>
|
|
{' '}
|
|
· {t('add.projEmitApr')}
|
|
<span className="dim"> {t('add.projIfStaked')}</span> ≈{' '}
|
|
<span className="green">{fmtApr(sim.emitApr)}</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
<span className="sd">
|
|
{t('add.projShare', { pct: sim.sharePct < 0.01 ? '<0.01' : sim.sharePct.toFixed(2) })}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="sv red">{t('add.projOut')}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{bandWarn && (
|
|
<div className="spec-row">
|
|
<span className="sk">!!</span>
|
|
<span className="sv amber">{bandWarn}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{planPaused && (
|
|
<div className="form-row">
|
|
<button
|
|
className="chip amber"
|
|
onClick={() => void plan.refetch()}
|
|
title={t('zap.refreshTip', { s: ZAP_PLAN_REFRESH_MS / 1000, n: SOLVER_AUTO_REFRESHES })}
|
|
>
|
|
{plan.isFetching ? <span className="spin">▮</span> : `↻ ${t('zap.refreshPlan')}`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{stages.length > 0 && (
|
|
<div className="zap-steps mono-sm">
|
|
{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 (
|
|
<div key={i} className={`zstep ${state}`}>
|
|
<span className="zn">{i + 1}</span>
|
|
<span className="zm">{mark}</span>
|
|
<span>{s.label}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{failed && (
|
|
<div className="red mono-sm">
|
|
{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 })}
|
|
</div>
|
|
)}
|
|
{done && <div className="green mono-sm">{t('zap.done')}</div>}
|
|
|
|
<div className="form-row">
|
|
<Btn
|
|
busy={running}
|
|
onClick={run}
|
|
disabled={!user || amount === 0n || !plan.data || insufficient || slippageRequired || running}
|
|
>
|
|
{runLabel}
|
|
</Btn>
|
|
{stakeable && <StakeAfterToggle disabled={running} />}
|
|
<span className="dim mono-sm">{t('zap.runHint')}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|