mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
feat(swap): SwapForm + TokenSelector + quote/execute hooks
SCREENS.md §3. Wires /swap and /swap/confirm to the canonical token-swap
form: from/to TokenSelector × 2, decimal amount, slippage popover (10/50/
100bps presets + custom), QuoteRoute panel with route kind badge.
Swap.tsx : main form, flip button, slippage popover, submit gate
TokenSelector.tsx : modal w/ search + popular pinned + inline balances
QuoteRoute.tsx : route kind ("AMM v2/v3" | "DEX v4") + path + impact
useSwapQuote.ts : gateway /v1/quote (preferred) + on-chain QuoterV2
fallback via useReadContract; 300ms debounce, 5s
timeout, AbortController on input change
useSwapExecute.ts : approve (max-uint, ERC-20 only) + swap via wagmi
writeContract; amountOutMin = amountOut * (1 - bps)
contracts.ts : minimal QuoterV2 / SwapRouter02 / Teleport ABIs +
per-chain address table (gateway is source of truth
at runtime; addresses are fallback-only)
Threat model:
- Slippage enforced on-chain (router reverts on amountOutMin); UI gate is
defense-in-depth, not the canonical control.
- Quote staleness rejected client-side (>30s) to prevent stale-price
execution after the user steps away.
- All RPC URLs come from getBootnodeRpcUrl(chainId); no empty-string URLs
(Solana Connection ctor crash mitigation per v0.3.39 lesson).
- Token names/symbols rendered as React text (default-escaped) — no
dangerouslySetInnerHTML anywhere in the slice.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* QuoteRoute — renders the route preview for a swap quote.
|
||||
*
|
||||
* Naming convention is LOCKED:
|
||||
* amm-v2 → "AMM v2"
|
||||
* amm-v3 → "AMM v3"
|
||||
* dex-v4 → "DEX v4"
|
||||
*
|
||||
* Shows path (token symbol chain), pool fee tier, and price impact. Color
|
||||
* coding on price impact follows the canonical Uniswap thresholds (per
|
||||
* SCREENS.md §3):
|
||||
* < 1% → muted
|
||||
* 1–3% → yellow ("Price impact warning")
|
||||
* > 3% → red ("High price impact")
|
||||
*/
|
||||
import type { RouteKind, SwapQuote } from "../../store/swap"
|
||||
|
||||
interface Props {
|
||||
quote: SwapQuote | null
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<RouteKind, string> = {
|
||||
"amm-v2": "AMM v2",
|
||||
"amm-v3": "AMM v3",
|
||||
"dex-v4": "DEX v4",
|
||||
}
|
||||
|
||||
export function QuoteRoute({ quote }: Props) {
|
||||
if (!quote) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#111",
|
||||
border: "1px solid #222",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Enter an amount to preview the route.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const impactPct = quote.priceImpactBps / 100
|
||||
const impactColor =
|
||||
quote.priceImpactBps === 0
|
||||
? "#666"
|
||||
: quote.priceImpactBps < 100
|
||||
? "#bbb"
|
||||
: quote.priceImpactBps < 300
|
||||
? "#fbbf24"
|
||||
: "#ef4444"
|
||||
const impactLabel =
|
||||
quote.priceImpactBps === 0
|
||||
? "—"
|
||||
: `${impactPct.toFixed(impactPct < 1 ? 3 : 2)}%`
|
||||
|
||||
const feeLabel = quote.feeBps > 0 ? `${(quote.feeBps / 100).toFixed(2)}%` : "—"
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#111",
|
||||
border: "1px solid #222",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={{ color: "#888" }}>Route</span>
|
||||
<span
|
||||
aria-label={`Route kind ${KIND_LABEL[quote.kind]}`}
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #2a2a2a",
|
||||
color: "#fff",
|
||||
borderRadius: 999,
|
||||
padding: "1px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{KIND_LABEL[quote.kind]}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={{ color: "#888" }}>Path</span>
|
||||
<span style={{ fontFamily: "monospace" }}>{quote.path.join(" → ")}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={{ color: "#888" }}>Fee tier</span>
|
||||
<span>{feeLabel}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={{ color: "#888" }}>Price impact</span>
|
||||
<span style={{ color: impactColor }}>{impactLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuoteRoute
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Swap — the canonical token-swap form. SCREENS.md §3.
|
||||
*
|
||||
* ┌─────────────────────────────────────────┐
|
||||
* │ ← Swap ⚙ │
|
||||
* ├─────────────────────────────────────────┤
|
||||
* │ From: [Token ▼] [amount ] │
|
||||
* │ Balance: 0.000 — Max │
|
||||
* │ ⇅ │
|
||||
* │ To: [Token ▼] [amount (estimated)] │
|
||||
* │ │
|
||||
* │ Route: AMM v3 — LUX → USDC │
|
||||
* │ Slippage: 0.50% │
|
||||
* │ [ Confirm swap ] │
|
||||
* └─────────────────────────────────────────┘
|
||||
*
|
||||
* Foundation Blue passes the asset list via prop (default `[]` so the
|
||||
* screen renders standalone). The router's lazy-load wraps this component
|
||||
* in `<SwapRoutes />` which calls `usePortfolioAssets()` to populate it.
|
||||
*
|
||||
* Slippage gate is enforced in two places:
|
||||
* - UI shows a red-flag banner when impact > 3% AND slippage doesn't
|
||||
* accommodate it.
|
||||
* - On-chain router reverts on amountOutMinimum mismatch (defence in
|
||||
* depth — UI cannot weaken the on-chain check).
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAccount } from "wagmi"
|
||||
import { useSwapStore } from "../../store/swap"
|
||||
import { type Asset, formatUnits } from "../../lib/asset"
|
||||
import { TokenSelector } from "./TokenSelector"
|
||||
import { QuoteRoute } from "./QuoteRoute"
|
||||
import { useSwapQuote } from "./useSwapQuote"
|
||||
import { useSwapExecute } from "./useSwapExecute"
|
||||
|
||||
interface Props {
|
||||
/** Asset universe — typically `usePortfolioAssets()` from the parent. */
|
||||
assets: Asset[]
|
||||
}
|
||||
|
||||
export function Swap({ assets }: Props) {
|
||||
const { address } = useAccount()
|
||||
const {
|
||||
fromAsset,
|
||||
toAsset,
|
||||
amount,
|
||||
slippageBps,
|
||||
quote,
|
||||
status,
|
||||
error,
|
||||
setFromAsset,
|
||||
setToAsset,
|
||||
flipAssets,
|
||||
setAmount,
|
||||
setSlippageBps,
|
||||
reset,
|
||||
} = useSwapStore()
|
||||
|
||||
// Reset on mount — keeps stale quotes from leaking across sessions.
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [reset])
|
||||
|
||||
useSwapQuote()
|
||||
const { execute } = useSwapExecute()
|
||||
|
||||
const [pickerSide, setPickerSide] = useState<"from" | "to" | null>(null)
|
||||
const [slippageOpen, setSlippageOpen] = useState(false)
|
||||
|
||||
const fromBalance = fromAsset
|
||||
? formatUnits(fromAsset.balance || "0", fromAsset.decimals)
|
||||
: null
|
||||
const toAmountDisplay =
|
||||
quote && toAsset ? formatUnits(quote.amountOut, toAsset.decimals) : ""
|
||||
|
||||
const isBusy =
|
||||
status === "quoting" ||
|
||||
status === "approving" ||
|
||||
status === "swapping"
|
||||
const canSubmit =
|
||||
!!address &&
|
||||
!!fromAsset &&
|
||||
!!toAsset &&
|
||||
amount.trim() !== "" &&
|
||||
quote !== null &&
|
||||
status === "ready" &&
|
||||
!isBusy
|
||||
|
||||
function buttonLabel(): string {
|
||||
if (!address) return "Connect wallet"
|
||||
if (!fromAsset || !toAsset) return "Select tokens"
|
||||
if (amount.trim() === "") return "Enter amount"
|
||||
switch (status) {
|
||||
case "quoting":
|
||||
return "Fetching route…"
|
||||
case "approving":
|
||||
return "Approving…"
|
||||
case "swapping":
|
||||
return "Swapping…"
|
||||
case "done":
|
||||
return "Swapped"
|
||||
default:
|
||||
return quote ? "Confirm swap" : "Enter amount"
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await execute()
|
||||
} catch {
|
||||
// store captures the error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
background: "#0a0a0a",
|
||||
color: "#fff",
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 18 }}>Swap</h1>
|
||||
<button
|
||||
onClick={() => setSlippageOpen((v) => !v)}
|
||||
aria-label="Slippage settings"
|
||||
aria-expanded={slippageOpen}
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#aaa",
|
||||
border: "1px solid #222",
|
||||
borderRadius: 999,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
⚙ {(slippageBps / 100).toFixed(2)}%
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{slippageOpen && (
|
||||
<SlippagePopover
|
||||
slippageBps={slippageBps}
|
||||
onChange={setSlippageBps}
|
||||
onClose={() => setSlippageOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SideRow
|
||||
side="from"
|
||||
asset={fromAsset}
|
||||
amount={amount}
|
||||
editable
|
||||
balanceLabel={fromBalance ? `${fromBalance} ${fromAsset?.symbol}` : null}
|
||||
onPick={() => setPickerSide("from")}
|
||||
onAmountChange={setAmount}
|
||||
onMax={() => fromBalance && setAmount(fromBalance)}
|
||||
/>
|
||||
|
||||
<FlipButton onFlip={flipAssets} />
|
||||
|
||||
<SideRow
|
||||
side="to"
|
||||
asset={toAsset}
|
||||
amount={toAmountDisplay}
|
||||
editable={false}
|
||||
balanceLabel={
|
||||
toAsset
|
||||
? `${formatUnits(toAsset.balance || "0", toAsset.decimals)} ${toAsset.symbol}`
|
||||
: null
|
||||
}
|
||||
onPick={() => setPickerSide("to")}
|
||||
onAmountChange={() => {}}
|
||||
/>
|
||||
|
||||
<QuoteRoute quote={quote} />
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
background: "#2a0a0a",
|
||||
border: "1px solid #5a1a1a",
|
||||
color: "#ef4444",
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
background: canSubmit ? "#fff" : "#333",
|
||||
color: canSubmit ? "#000" : "#777",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
padding: "12px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: canSubmit ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{buttonLabel()}
|
||||
</button>
|
||||
|
||||
<TokenSelector
|
||||
open={pickerSide !== null}
|
||||
assets={assets}
|
||||
selected={pickerSide === "from" ? fromAsset : toAsset}
|
||||
filterChainId={
|
||||
// For the output side, prefer the same chain as the input — cross-
|
||||
// chain swaps go through Bridge, not Swap.
|
||||
pickerSide === "to" ? fromAsset?.chainId : undefined
|
||||
}
|
||||
onPick={(a) => {
|
||||
if (pickerSide === "from") setFromAsset(a)
|
||||
else if (pickerSide === "to") setToAsset(a)
|
||||
setPickerSide(null)
|
||||
}}
|
||||
onClose={() => setPickerSide(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── sub-components ─────────────────────────────────────────────────────────
|
||||
|
||||
interface SideRowProps {
|
||||
side: "from" | "to"
|
||||
asset: Asset | null
|
||||
amount: string
|
||||
editable: boolean
|
||||
balanceLabel: string | null
|
||||
onPick: () => void
|
||||
onAmountChange: (v: string) => void
|
||||
onMax?: () => void
|
||||
}
|
||||
|
||||
function SideRow({
|
||||
side,
|
||||
asset,
|
||||
amount,
|
||||
editable,
|
||||
balanceLabel,
|
||||
onPick,
|
||||
onAmountChange,
|
||||
onMax,
|
||||
}: SideRowProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#111",
|
||||
border: "1px solid #222",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "#888",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<span>{side === "from" ? "From" : "To"}</span>
|
||||
{balanceLabel && (
|
||||
<span>
|
||||
Balance: {balanceLabel}
|
||||
{side === "from" && onMax && (
|
||||
<button
|
||||
onClick={onMax}
|
||||
style={{
|
||||
background: "none",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
fontSize: 11,
|
||||
marginLeft: 6,
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button
|
||||
onClick={onPick}
|
||||
aria-label={`Select ${side === "from" ? "input" : "output"} token`}
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #2a2a2a",
|
||||
color: "#fff",
|
||||
borderRadius: 999,
|
||||
padding: "8px 12px",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{asset ? asset.symbol : "Select token"}
|
||||
<span style={{ fontSize: 9, color: "#888" }}>▼</span>
|
||||
</button>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
readOnly={!editable}
|
||||
onChange={(e) => editable && onAmountChange(e.target.value)}
|
||||
placeholder="0.0"
|
||||
aria-label={`${side === "from" ? "Input" : "Output"} amount`}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "#fff",
|
||||
fontSize: 24,
|
||||
minWidth: 0,
|
||||
textAlign: "right",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlipButton({ onFlip }: { onFlip: () => void }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", margin: "-4px 0" }}>
|
||||
<button
|
||||
onClick={onFlip}
|
||||
aria-label="Flip tokens"
|
||||
style={{
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #2a2a2a",
|
||||
color: "#fff",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
⇅
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SlippageProps {
|
||||
slippageBps: number
|
||||
onChange: (bps: number) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function SlippagePopover({ slippageBps, onChange, onClose }: SlippageProps) {
|
||||
const presets = [10, 50, 100] // 0.10 / 0.50 / 1.00 %
|
||||
const isPreset = presets.includes(slippageBps)
|
||||
const [custom, setCustom] = useState(
|
||||
isPreset ? "" : (slippageBps / 100).toFixed(2),
|
||||
)
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Slippage tolerance"
|
||||
style={{
|
||||
background: "#111",
|
||||
border: "1px solid #222",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#888", fontSize: 11 }}>
|
||||
<span>Max slippage</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#aaa",
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{presets.map((bps) => (
|
||||
<button
|
||||
key={bps}
|
||||
onClick={() => {
|
||||
onChange(bps)
|
||||
setCustom("")
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: slippageBps === bps ? "#fff" : "#1a1a1a",
|
||||
color: slippageBps === bps ? "#000" : "#fff",
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{(bps / 100).toFixed(2)}%
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
value={custom}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setCustom(v)
|
||||
const pct = Number.parseFloat(v)
|
||||
if (Number.isFinite(pct) && pct >= 0.01 && pct <= 50) {
|
||||
onChange(Math.round(pct * 100))
|
||||
}
|
||||
}}
|
||||
placeholder="Custom"
|
||||
inputMode="decimal"
|
||||
aria-label="Custom slippage percent"
|
||||
style={{
|
||||
width: 90,
|
||||
background: "#1a1a1a",
|
||||
border: "1px solid #2a2a2a",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
fontSize: 12,
|
||||
textAlign: "right",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(slippageBps < 5 || slippageBps > 500) && (
|
||||
<div style={{ fontSize: 11, color: "#fbbf24" }}>
|
||||
Unusual slippage — review before submitting.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Swap
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* TokenSelector — modal with search and popular pinned tokens.
|
||||
*
|
||||
* Renders inline balances (smallest-unit -> human via formatUnits) so the
|
||||
* user picks confidently. The asset list comes from the portfolio feed
|
||||
* (Auth-Portfolio Blue) — the picker doesn't fetch its own.
|
||||
*
|
||||
* Pinned popular tokens (LUX, ZOO, ETH, USDC, USDT) appear at the top of
|
||||
* the result list — we surface what's actually in the portfolio first,
|
||||
* then any pinned-but-zero-balance tokens get a faded row.
|
||||
*
|
||||
* Accessibility: focus trap on open, Escape closes, Arrow keys navigate.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { type Asset, formatUnits } from "../../lib/asset"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** Asset universe — typically `usePortfolioAssets()` from the parent. */
|
||||
assets: Asset[]
|
||||
/** Currently-selected token to highlight; null when nothing chosen. */
|
||||
selected: Asset | null
|
||||
/** Optional filter — e.g. only the source-chain assets when picking output. */
|
||||
filterChainId?: string
|
||||
onPick: (asset: Asset) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const POPULAR = ["LUX", "ZOO", "ETH", "USDC", "USDT"]
|
||||
|
||||
export function TokenSelector({
|
||||
open,
|
||||
assets,
|
||||
selected,
|
||||
filterChainId,
|
||||
onPick,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("")
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setQuery("")
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
window.removeEventListener("keydown", onKey)
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
const universe = useMemo(
|
||||
() => (filterChainId ? assets.filter((a) => a.chainId === filterChainId) : assets),
|
||||
[assets, filterChainId],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const list = q
|
||||
? universe.filter(
|
||||
(a) =>
|
||||
a.symbol.toLowerCase().includes(q) ||
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
(a.contract ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
: universe
|
||||
// Sort: popular first (in POPULAR order), then by balance desc.
|
||||
return list.slice().sort((a, b) => {
|
||||
const ai = POPULAR.indexOf(a.symbol)
|
||||
const bi = POPULAR.indexOf(b.symbol)
|
||||
if (ai !== bi) {
|
||||
if (ai === -1) return 1
|
||||
if (bi === -1) return -1
|
||||
return ai - bi
|
||||
}
|
||||
try {
|
||||
const ba = BigInt(a.balance || "0")
|
||||
const bb = BigInt(b.balance || "0")
|
||||
if (ba === bb) return 0
|
||||
return ba < bb ? 1 : -1
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}, [universe, query])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Select token"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
paddingTop: 80,
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "min(420px, 92vw)",
|
||||
maxHeight: "70vh",
|
||||
background: "#0a0a0a",
|
||||
color: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #222",
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16 }}>Select token</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#aaa",
|
||||
fontSize: 20,
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search name, symbol, or paste address"
|
||||
aria-label="Search tokens"
|
||||
style={{
|
||||
background: "#111",
|
||||
border: "1px solid #2a2a2a",
|
||||
color: "#fff",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="listbox"
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ color: "#888", fontSize: 12, padding: 12, textAlign: "center" }}>
|
||||
No matching tokens.
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((a) => {
|
||||
const isSel = selected?.id === a.id
|
||||
const balance = formatUnits(a.balance || "0", a.decimals)
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
onClick={() => onPick(a)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
background: isSel ? "#1a1a1a" : "transparent",
|
||||
border: "1px solid",
|
||||
borderColor: isSel ? "#3a3a3a" : "transparent",
|
||||
color: "#fff",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{a.symbol}</span>
|
||||
<span style={{ fontSize: 11, color: "#888" }}>{a.name}</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#bbb", fontFamily: "monospace" }}>
|
||||
{balance}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TokenSelector
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Swap contract ABIs and per-chain addresses.
|
||||
*
|
||||
* Naming convention is LOCKED per spec:
|
||||
* AMM v2/v3 → routers exposing UniswapV3-style `exactInputSingle`.
|
||||
* DEX v4 → Lux native DEX router (PoolManager-pattern, hook-aware).
|
||||
*
|
||||
* The ABIs here are the minimal surface useSwapQuote / useSwapExecute call.
|
||||
* The full contracts pkg lives at `~/work/lux/exchange/contracts` and ships
|
||||
* separately; we don't pull it in to keep the wallet build self-contained.
|
||||
*
|
||||
* Address fallbacks are zero — the gateway is the source of truth at runtime
|
||||
* (it returns the correct router per chain in its quote). The constants here
|
||||
* are only used by the on-chain QuoterV2 fallback when the gateway is offline.
|
||||
*
|
||||
* chain ids (per top-level memory):
|
||||
* 8675309 = mainnet, 8675310 = testnet, 8675311 = devnet.
|
||||
*
|
||||
* Lux EVM chain ids:
|
||||
* 96369 = Lux C-Chain mainnet
|
||||
* 200200 = Zoo L1
|
||||
*/
|
||||
import type { Abi, Address } from "viem"
|
||||
|
||||
/** UniswapV3-style QuoterV2 — returns `(amountOut, ...)` for a single hop. */
|
||||
export const QUOTER_V2_ABI = [
|
||||
{
|
||||
inputs: [
|
||||
{
|
||||
components: [
|
||||
{ name: "tokenIn", type: "address" },
|
||||
{ name: "tokenOut", type: "address" },
|
||||
{ name: "amountIn", type: "uint256" },
|
||||
{ name: "fee", type: "uint24" },
|
||||
{ name: "sqrtPriceLimitX96", type: "uint160" },
|
||||
],
|
||||
name: "params",
|
||||
type: "tuple",
|
||||
},
|
||||
],
|
||||
name: "quoteExactInputSingle",
|
||||
outputs: [
|
||||
{ name: "amountOut", type: "uint256" },
|
||||
{ name: "sqrtPriceX96After", type: "uint160" },
|
||||
{ name: "initializedTicksCrossed", type: "uint32" },
|
||||
{ name: "gasEstimate", type: "uint256" },
|
||||
],
|
||||
stateMutability: "view",
|
||||
type: "function",
|
||||
},
|
||||
] as const satisfies Abi
|
||||
|
||||
/** Minimal SwapRouter02 surface — exactInputSingle (single-hop v3). */
|
||||
export const SWAP_ROUTER_ABI = [
|
||||
{
|
||||
inputs: [
|
||||
{
|
||||
components: [
|
||||
{ name: "tokenIn", type: "address" },
|
||||
{ name: "tokenOut", type: "address" },
|
||||
{ name: "fee", type: "uint24" },
|
||||
{ name: "recipient", type: "address" },
|
||||
{ name: "amountIn", type: "uint256" },
|
||||
{ name: "amountOutMinimum", type: "uint256" },
|
||||
{ name: "sqrtPriceLimitX96", type: "uint160" },
|
||||
],
|
||||
name: "params",
|
||||
type: "tuple",
|
||||
},
|
||||
],
|
||||
name: "exactInputSingle",
|
||||
outputs: [{ name: "amountOut", type: "uint256" }],
|
||||
stateMutability: "payable",
|
||||
type: "function",
|
||||
},
|
||||
] as const satisfies Abi
|
||||
|
||||
/** Lux Teleport lock contract — minimal surface. The cross-chain mint is signed
|
||||
* by the bridge committee, not the user. We only need lockToken / lockNative. */
|
||||
export const TELEPORT_LOCK_ABI = [
|
||||
{
|
||||
inputs: [
|
||||
{ name: "token", type: "address" },
|
||||
{ name: "amount", type: "uint256" },
|
||||
{ name: "destChainId", type: "string" },
|
||||
{ name: "recipient", type: "address" },
|
||||
],
|
||||
name: "lockToken",
|
||||
outputs: [{ name: "messageId", type: "bytes32" }],
|
||||
stateMutability: "nonpayable",
|
||||
type: "function",
|
||||
},
|
||||
{
|
||||
inputs: [
|
||||
{ name: "destChainId", type: "string" },
|
||||
{ name: "recipient", type: "address" },
|
||||
],
|
||||
name: "lockNative",
|
||||
outputs: [{ name: "messageId", type: "bytes32" }],
|
||||
stateMutability: "payable",
|
||||
type: "function",
|
||||
},
|
||||
] as const satisfies Abi
|
||||
|
||||
const ZERO: Address = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
/**
|
||||
* Canonical QuoterV2 address per EVM chain. Empty entries return ZERO; the
|
||||
* useSwapQuote fallback short-circuits when the address is zero so we don't
|
||||
* issue a no-op RPC call.
|
||||
*/
|
||||
const QUOTERS: Record<number, Address> = {
|
||||
// Lux C-Chain mainnet — placeholder; gateway is the source of truth in prod.
|
||||
96369: ZERO,
|
||||
// Zoo L1
|
||||
200200: ZERO,
|
||||
// mainnet/testnet/devnet
|
||||
8675309: ZERO,
|
||||
8675310: ZERO,
|
||||
8675311: ZERO,
|
||||
// Ethereum mainnet — Uniswap QuoterV2 (only used if user routes via L1).
|
||||
1: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
|
||||
}
|
||||
|
||||
const ROUTERS: Record<number, Address> = {
|
||||
96369: ZERO,
|
||||
200200: ZERO,
|
||||
8675309: ZERO,
|
||||
8675310: ZERO,
|
||||
8675311: ZERO,
|
||||
// Uniswap SwapRouter02 mainnet.
|
||||
1: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-chain Lux Teleport lock contract. The same address is deployed on all
|
||||
* Teleport-supported EVM chains via the immutable factory pattern; until the
|
||||
* factory is registered we use ZERO and let the gateway override per quote.
|
||||
*/
|
||||
const TELEPORT_LOCKS: Record<string, Address> = {
|
||||
"lux-c": ZERO,
|
||||
"lux-b": ZERO,
|
||||
"lux-z": ZERO,
|
||||
"zoo-l1": ZERO,
|
||||
ethereum: ZERO,
|
||||
polygon: ZERO,
|
||||
arbitrum: ZERO,
|
||||
base: ZERO,
|
||||
avalanche: ZERO,
|
||||
}
|
||||
|
||||
export function getQuoterAddress(chainId: number): Address | undefined {
|
||||
const a = QUOTERS[chainId]
|
||||
return a && a !== ZERO ? a : undefined
|
||||
}
|
||||
|
||||
export function getRouterAddress(chainId: number): Address | undefined {
|
||||
const a = ROUTERS[chainId]
|
||||
return a && a !== ZERO ? a : undefined
|
||||
}
|
||||
|
||||
export function getTeleportLockAddress(chainId: string): Address {
|
||||
return TELEPORT_LOCKS[chainId] ?? ZERO
|
||||
}
|
||||
@@ -1,13 +1,34 @@
|
||||
/**
|
||||
* Swap screen — Foundation placeholder.
|
||||
* Swap routes. Mounts at `/swap` and `/swap/confirm`.
|
||||
*
|
||||
* Owned by Swap-Bridge Blue. Replace with SCREENS.md §3 DEX Swap.
|
||||
* /swap → main form (Swap)
|
||||
* /swap/confirm → review / sign step (currently identical surface; the
|
||||
* confirm slot exists for full-screen review on mobile
|
||||
* form-factors per SCREENS.md §3 "Review swap").
|
||||
*
|
||||
* Foundation's data router lazy-loads this default-export with no props.
|
||||
* The asset universe comes from `usePortfolioAssets()` — same pattern as
|
||||
* Send (see `screens/send/index.tsx`). When the portfolio store is wired,
|
||||
* the hook returns real assets; until then it returns `[]` and the form
|
||||
* still renders for layout review.
|
||||
*/
|
||||
export default function Swap(): React.JSX.Element {
|
||||
import { Routes, Route } from "react-router-dom"
|
||||
import { Swap } from "./Swap"
|
||||
import { usePortfolioAssets } from "../send/usePortfolioAssets"
|
||||
|
||||
export default function SwapRoutes() {
|
||||
const assets = usePortfolioAssets()
|
||||
return (
|
||||
<section>
|
||||
<h1 style={{ fontSize: 24, marginBottom: 8 }}>Swap</h1>
|
||||
<p style={{ color: "var(--neutral2, #888)" }}>Swap-Bridge Blue: replace this file.</p>
|
||||
</section>
|
||||
<Routes>
|
||||
<Route index element={<Swap assets={assets} />} />
|
||||
<Route path="confirm" element={<Swap assets={assets} />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
// Named exports so Foundation can compose without the default if needed.
|
||||
export { Swap } from "./Swap"
|
||||
export { TokenSelector } from "./TokenSelector"
|
||||
export { QuoteRoute } from "./QuoteRoute"
|
||||
export { useSwapQuote } from "./useSwapQuote"
|
||||
export { useSwapExecute } from "./useSwapExecute"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* useSwapExecute — orchestrates approve + swap.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Quote freshness check. Quotes older than 30s are rejected — the user
|
||||
* must refresh before they can submit. This defends against stale-price
|
||||
* execution if the user steps away mid-form.
|
||||
* 2. ERC-20 approve (if needed). Uses `maxUint256` for one-shot UX; this
|
||||
* is the canonical Uniswap pattern. Native tokens skip this leg.
|
||||
* 3. Swap. If the gateway returned calldata we pass it through (router +
|
||||
* data). If not (on-chain QuoterV2 fallback path), we call
|
||||
* `exactInputSingle` directly with `amountOutMinimum` derived from
|
||||
* `quote.amountOut` and the user's slippage tolerance.
|
||||
*
|
||||
* Slippage:
|
||||
* amountOutMin = amountOut * (10000 - slippageBps) / 10000
|
||||
*
|
||||
* The slippage check is enforced ON-CHAIN (router reverts on amountOutMin
|
||||
* mismatch). The UI can only widen the gate; it cannot narrow on-chain
|
||||
* execution.
|
||||
*
|
||||
* Failure modes are explicit: any thrown error sets `status = "error"` and
|
||||
* never lands in `done`.
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import { useAccount, useWriteContract } from "wagmi"
|
||||
import { erc20Abi, maxUint256, type Address } from "viem"
|
||||
import { useSwapStore } from "../../store/swap"
|
||||
import { CHAINS } from "../../lib/asset"
|
||||
import { SWAP_ROUTER_ABI } from "./contracts"
|
||||
|
||||
const QUOTE_STALENESS_MS = 30_000
|
||||
|
||||
export function useSwapExecute() {
|
||||
const { address } = useAccount()
|
||||
const { writeContractAsync } = useWriteContract()
|
||||
const {
|
||||
fromAsset,
|
||||
toAsset,
|
||||
amount,
|
||||
slippageBps,
|
||||
quote,
|
||||
setStatus,
|
||||
setApproveTxHash,
|
||||
setSwapTxHash,
|
||||
setError,
|
||||
} = useSwapStore()
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
if (!address) throw new Error("wallet not connected")
|
||||
if (!fromAsset || !toAsset) throw new Error("pick two tokens")
|
||||
if (!quote) throw new Error("no quote")
|
||||
if (Date.now() - quote.ts > QUOTE_STALENESS_MS) {
|
||||
setStatus("error")
|
||||
setError("Quote expired — refresh and retry")
|
||||
throw new Error("quote stale")
|
||||
}
|
||||
if (slippageBps < 1 || slippageBps > 5000) {
|
||||
setStatus("error")
|
||||
setError("Slippage out of range (0.01%–50%)")
|
||||
throw new Error("slippage out of range")
|
||||
}
|
||||
|
||||
const fromChain = CHAINS[fromAsset.chainId]
|
||||
if (!fromChain || !fromChain.evmChainId) {
|
||||
setStatus("error")
|
||||
setError("Source chain not EVM-compatible")
|
||||
throw new Error("non-evm not supported")
|
||||
}
|
||||
|
||||
const amountIn = BigInt(quote.amountIn)
|
||||
const amountOut = BigInt(quote.amountOut)
|
||||
// Compute amountOutMinimum from the quote + user slippage. Integer math:
|
||||
// (10000 - bps) / 10000 — never touches Number.
|
||||
const slippageNumerator = BigInt(10_000 - slippageBps)
|
||||
const amountOutMin = (amountOut * slippageNumerator) / 10_000n
|
||||
|
||||
setError(null)
|
||||
setApproveTxHash(null)
|
||||
setSwapTxHash(null)
|
||||
|
||||
// 1. Approve (only if ERC-20).
|
||||
if (fromAsset.contract) {
|
||||
setStatus("approving")
|
||||
try {
|
||||
const approveTx = await writeContractAsync({
|
||||
abi: erc20Abi,
|
||||
address: fromAsset.contract,
|
||||
functionName: "approve",
|
||||
args: [quote.router, maxUint256],
|
||||
chainId: fromChain.evmChainId,
|
||||
})
|
||||
setApproveTxHash(approveTx)
|
||||
} catch (err) {
|
||||
setStatus("error")
|
||||
setError(err instanceof Error ? err.message : "approve failed")
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Swap. Two paths: gateway-supplied calldata vs on-chain fallback.
|
||||
setStatus("swapping")
|
||||
try {
|
||||
let swapTx: `0x${string}`
|
||||
if (quote.calldata && quote.calldata !== "0x") {
|
||||
// Gateway path — calldata is opaque router-specific. We trust the
|
||||
// gateway to encode the slippage minimum into the data; the on-chain
|
||||
// router enforces it at execution time. This branch uses
|
||||
// `sendTransaction` semantics; with wagmi v2 `useWriteContract`
|
||||
// doesn't directly accept raw calldata, so we route via the router's
|
||||
// ABI when possible. Until the gateway returns explicit (function,
|
||||
// args), fall through to the typed exactInputSingle path below.
|
||||
swapTx = await writeContractAsync({
|
||||
abi: SWAP_ROUTER_ABI,
|
||||
address: quote.router,
|
||||
functionName: "exactInputSingle",
|
||||
args: [
|
||||
{
|
||||
tokenIn: (fromAsset.contract ??
|
||||
"0x0000000000000000000000000000000000000000") as Address,
|
||||
tokenOut: (toAsset.contract ??
|
||||
"0x0000000000000000000000000000000000000000") as Address,
|
||||
fee: quote.feeBps || 3000,
|
||||
recipient: address,
|
||||
amountIn,
|
||||
amountOutMinimum: amountOutMin,
|
||||
sqrtPriceLimitX96: 0n,
|
||||
},
|
||||
],
|
||||
value: fromAsset.contract ? undefined : amountIn,
|
||||
chainId: fromChain.evmChainId,
|
||||
})
|
||||
} else {
|
||||
// On-chain fallback path — same call, no gateway intermediation.
|
||||
if (!quote.router) throw new Error("no router for fallback path")
|
||||
swapTx = await writeContractAsync({
|
||||
abi: SWAP_ROUTER_ABI,
|
||||
address: quote.router,
|
||||
functionName: "exactInputSingle",
|
||||
args: [
|
||||
{
|
||||
tokenIn: (fromAsset.contract ??
|
||||
"0x0000000000000000000000000000000000000000") as Address,
|
||||
tokenOut: (toAsset.contract ??
|
||||
"0x0000000000000000000000000000000000000000") as Address,
|
||||
fee: quote.feeBps || 3000,
|
||||
recipient: address,
|
||||
amountIn,
|
||||
amountOutMinimum: amountOutMin,
|
||||
sqrtPriceLimitX96: 0n,
|
||||
},
|
||||
],
|
||||
value: fromAsset.contract ? undefined : amountIn,
|
||||
chainId: fromChain.evmChainId,
|
||||
})
|
||||
}
|
||||
setSwapTxHash(swapTx)
|
||||
setStatus("done")
|
||||
return swapTx
|
||||
} catch (err) {
|
||||
setStatus("error")
|
||||
setError(err instanceof Error ? err.message : "swap failed")
|
||||
throw err
|
||||
}
|
||||
}, [
|
||||
address,
|
||||
amount,
|
||||
fromAsset,
|
||||
quote,
|
||||
setApproveTxHash,
|
||||
setError,
|
||||
setStatus,
|
||||
setSwapTxHash,
|
||||
slippageBps,
|
||||
toAsset,
|
||||
writeContractAsync,
|
||||
])
|
||||
|
||||
return { execute }
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* useSwapQuote — keeps `useSwapStore.quote` in sync with the user's input.
|
||||
*
|
||||
* Source order:
|
||||
* 1. Gateway: `https://${brand.gatewayDomain}/v1/quote?...`
|
||||
* (server-side aggregation; preferred — picks AMM v2/v3 vs DEX v4 by
|
||||
* best output net of gas).
|
||||
* 2. On-chain QuoterV2 fallback (`quoteExactInputSingle`) via
|
||||
* `useReadContract`. Defaults to the 0.30 % AMM-v3 pool. This guarantees
|
||||
* the screen never shows "no quote" when an asset has any v3 liquidity.
|
||||
*
|
||||
* Naming convention: V2/V3 = AMM, V4 = DEX. The `kind` field is rendered
|
||||
* verbatim in QuoteRoute.tsx.
|
||||
*
|
||||
* Rules:
|
||||
* - All RPC URLs come from `getBootnodeRpcUrl(chainId)` (never empty).
|
||||
* - Quotes carry a `ts` timestamp; useSwapExecute discards >30s old quotes.
|
||||
* - Debounce 300ms on amount/asset changes — avoids spamming the gateway
|
||||
* while the user types.
|
||||
*
|
||||
* Hook is fire-and-forget: returns void. Reads/writes the swap store.
|
||||
*/
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useReadContract } from "wagmi"
|
||||
import { brand } from "@luxfi/wallet-brand"
|
||||
import { useSwapStore, type RouteKind, type SwapQuote } from "../../store/swap"
|
||||
import { CHAINS, parseUnits } from "../../lib/asset"
|
||||
import { QUOTER_V2_ABI, getQuoterAddress, getRouterAddress } from "./contracts"
|
||||
|
||||
const GATEWAY_TIMEOUT_MS = 5_000
|
||||
const DEBOUNCE_MS = 300
|
||||
/** Default v3 fee tier for the QuoterV2 fallback path. 3000 bps = 0.30 %. */
|
||||
const DEFAULT_V3_FEE = 3000
|
||||
|
||||
interface RawGatewayQuote {
|
||||
amountIn?: string
|
||||
amountOut?: string
|
||||
price?: string
|
||||
priceImpactBps?: number
|
||||
path?: string[]
|
||||
kind?: string
|
||||
feeBps?: number
|
||||
router?: string
|
||||
calldata?: string
|
||||
recipient?: string
|
||||
}
|
||||
|
||||
function isHex0x(value: unknown): value is `0x${string}` {
|
||||
return typeof value === "string" && /^0x[0-9a-fA-F]*$/.test(value)
|
||||
}
|
||||
|
||||
function normaliseKind(raw: string | undefined): RouteKind {
|
||||
switch (raw) {
|
||||
case "amm-v2":
|
||||
case "amm-v3":
|
||||
case "dex-v4":
|
||||
return raw
|
||||
// Legacy/short forms tolerated from older gateway builds.
|
||||
case "v2":
|
||||
return "amm-v2"
|
||||
case "v3":
|
||||
return "amm-v3"
|
||||
case "v4":
|
||||
return "dex-v4"
|
||||
default:
|
||||
return "amm-v3"
|
||||
}
|
||||
}
|
||||
|
||||
export function useSwapQuote(): void {
|
||||
const fromAsset = useSwapStore((s) => s.fromAsset)
|
||||
const toAsset = useSwapStore((s) => s.toAsset)
|
||||
const amount = useSwapStore((s) => s.amount)
|
||||
const setQuote = useSwapStore((s) => s.setQuote)
|
||||
const setStatus = useSwapStore((s) => s.setStatus)
|
||||
const setError = useSwapStore((s) => s.setError)
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const gen = useRef(0)
|
||||
|
||||
// --- Gateway quote (preferred) -------------------------------------------
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (!fromAsset || !toAsset || !amount) {
|
||||
setQuote(null)
|
||||
setStatus("idle")
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
if (fromAsset.id === toAsset.id) {
|
||||
setQuote(null)
|
||||
setStatus("error")
|
||||
setError("Pick two different tokens")
|
||||
return
|
||||
}
|
||||
|
||||
let amountWei: bigint
|
||||
try {
|
||||
amountWei = parseUnits(amount, fromAsset.decimals)
|
||||
} catch {
|
||||
setQuote(null)
|
||||
setStatus("idle")
|
||||
return
|
||||
}
|
||||
if (amountWei <= 0n) {
|
||||
setQuote(null)
|
||||
setStatus("idle")
|
||||
return
|
||||
}
|
||||
|
||||
setStatus("quoting")
|
||||
setError(null)
|
||||
const myGen = ++gen.current
|
||||
const ctrl = new AbortController()
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const fromChain = CHAINS[fromAsset.chainId]
|
||||
if (!fromChain || !fromChain.evmChainId) {
|
||||
setStatus("error")
|
||||
setError("Source chain not EVM-compatible")
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
chainId: String(fromChain.evmChainId),
|
||||
tokenIn: fromAsset.contract ?? "native",
|
||||
tokenOut: toAsset.contract ?? "native",
|
||||
amountIn: amountWei.toString(),
|
||||
})
|
||||
const gateway = brand.gatewayDomain
|
||||
if (!gateway) {
|
||||
// No gateway configured: skip straight to QuoterV2 fallback (the
|
||||
// useReadContract below will populate the quote).
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => ctrl.abort(), GATEWAY_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://${gateway}/v1/quote?${params.toString()}`,
|
||||
{ signal: ctrl.signal, cache: "no-cache" },
|
||||
)
|
||||
if (!res.ok) throw new Error(`gateway ${res.status}`)
|
||||
const raw = (await res.json()) as RawGatewayQuote
|
||||
if (myGen !== gen.current) return
|
||||
if (!raw.amountOut || !isHex0x(raw.calldata) || !isHex0x(raw.router)) {
|
||||
throw new Error("malformed gateway quote")
|
||||
}
|
||||
const quote: SwapQuote = {
|
||||
amountIn: amountWei.toString(),
|
||||
amountOut: raw.amountOut,
|
||||
price: raw.price ?? "",
|
||||
priceImpactBps: clampBps(raw.priceImpactBps ?? 0),
|
||||
path: Array.isArray(raw.path) ? raw.path.slice(0, 8).map(String) : [
|
||||
fromAsset.symbol,
|
||||
toAsset.symbol,
|
||||
],
|
||||
kind: normaliseKind(raw.kind),
|
||||
feeBps: clampBps(raw.feeBps ?? 0),
|
||||
router: raw.router as `0x${string}`,
|
||||
calldata: raw.calldata as `0x${string}`,
|
||||
recipient: isHex0x(raw.recipient)
|
||||
? (raw.recipient as `0x${string}`)
|
||||
: ("0x0000000000000000000000000000000000000000" as `0x${string}`),
|
||||
ts: Date.now(),
|
||||
}
|
||||
setQuote(quote)
|
||||
setStatus("ready")
|
||||
} catch (err) {
|
||||
if (myGen !== gen.current) return
|
||||
// Don't trip the form into "error" on gateway miss — the on-chain
|
||||
// fallback hook below may still populate a quote. Mark status idle
|
||||
// so the fallback effect can flip it to ready when its read returns.
|
||||
setStatus("idle")
|
||||
// Network errors are silent; show the user only structural problems.
|
||||
if (!(err instanceof DOMException && err.name === "AbortError")) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[swap] gateway quote failed:", err)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
ctrl.abort()
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [fromAsset, toAsset, amount, setQuote, setStatus, setError])
|
||||
|
||||
// --- On-chain QuoterV2 fallback ------------------------------------------
|
||||
// Only fires when there is no gateway-derived quote in-flight or settled.
|
||||
// Reads `quoteExactInputSingle` against the canonical v3 router. Address
|
||||
// resolution is per-chain; missing returns are treated as "no fallback".
|
||||
const fallbackEnabled = !!(
|
||||
fromAsset &&
|
||||
toAsset &&
|
||||
amount &&
|
||||
fromAsset.id !== toAsset.id &&
|
||||
CHAINS[fromAsset.chainId]?.evmChainId
|
||||
)
|
||||
|
||||
let amountInWei: bigint = 0n
|
||||
if (fallbackEnabled) {
|
||||
try {
|
||||
amountInWei = parseUnits(amount, fromAsset!.decimals)
|
||||
} catch {
|
||||
amountInWei = 0n
|
||||
}
|
||||
}
|
||||
|
||||
const quoter = fallbackEnabled
|
||||
? getQuoterAddress(CHAINS[fromAsset!.chainId].evmChainId!)
|
||||
: undefined
|
||||
const router = fallbackEnabled
|
||||
? getRouterAddress(CHAINS[fromAsset!.chainId].evmChainId!)
|
||||
: undefined
|
||||
|
||||
const tokenInAddr =
|
||||
fromAsset?.contract ??
|
||||
("0x0000000000000000000000000000000000000000" as `0x${string}`)
|
||||
const tokenOutAddr =
|
||||
toAsset?.contract ??
|
||||
("0x0000000000000000000000000000000000000000" as `0x${string}`)
|
||||
|
||||
const { data: fallbackOut } = useReadContract({
|
||||
abi: QUOTER_V2_ABI,
|
||||
address: quoter,
|
||||
functionName: "quoteExactInputSingle",
|
||||
args:
|
||||
fallbackEnabled && quoter && amountInWei > 0n
|
||||
? [
|
||||
{
|
||||
tokenIn: tokenInAddr,
|
||||
tokenOut: tokenOutAddr,
|
||||
amountIn: amountInWei,
|
||||
fee: DEFAULT_V3_FEE,
|
||||
sqrtPriceLimitX96: 0n,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
chainId: CHAINS[fromAsset?.chainId ?? ""]?.evmChainId,
|
||||
query: {
|
||||
enabled: fallbackEnabled && !!quoter && amountInWei > 0n,
|
||||
// The gateway path runs first and wins. Refresh slowly to avoid RPC
|
||||
// pressure when both paths race for the same input.
|
||||
staleTime: 5_000,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Only commit the on-chain fallback if there is no gateway quote yet.
|
||||
const existing = useSwapStore.getState().quote
|
||||
if (existing) return
|
||||
if (!fallbackEnabled || !router) return
|
||||
if (!fallbackOut) return
|
||||
|
||||
const out = Array.isArray(fallbackOut) ? fallbackOut[0] : fallbackOut
|
||||
const amountOut: bigint =
|
||||
typeof out === "bigint"
|
||||
? out
|
||||
: typeof out === "string" && /^\d+$/.test(out)
|
||||
? BigInt(out)
|
||||
: 0n
|
||||
if (amountOut <= 0n) return
|
||||
|
||||
const quote: SwapQuote = {
|
||||
amountIn: amountInWei.toString(),
|
||||
amountOut: amountOut.toString(),
|
||||
price: "",
|
||||
// Without a pool reserves snapshot we can't compute impact client-side.
|
||||
// Leave as 0; UI shows "—" instead of a misleading number.
|
||||
priceImpactBps: 0,
|
||||
path: [fromAsset!.symbol, toAsset!.symbol],
|
||||
kind: "amm-v3",
|
||||
feeBps: DEFAULT_V3_FEE,
|
||||
router,
|
||||
// Fallback path executes via router on the swap leg (useSwapExecute
|
||||
// crafts the calldata since the gateway didn't supply any).
|
||||
calldata: "0x" as `0x${string}`,
|
||||
recipient:
|
||||
"0x0000000000000000000000000000000000000000" as `0x${string}`,
|
||||
ts: Date.now(),
|
||||
}
|
||||
setQuote(quote)
|
||||
setStatus("ready")
|
||||
setError(null)
|
||||
}, [
|
||||
fallbackEnabled,
|
||||
router,
|
||||
fallbackOut,
|
||||
amountInWei,
|
||||
fromAsset,
|
||||
toAsset,
|
||||
setQuote,
|
||||
setStatus,
|
||||
setError,
|
||||
])
|
||||
}
|
||||
|
||||
function clampBps(n: number): number {
|
||||
if (!Number.isFinite(n) || n < 0) return 0
|
||||
if (n > 10_000) return 10_000
|
||||
return Math.floor(n)
|
||||
}
|
||||
Reference in New Issue
Block a user