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:
Hanzo AI
2026-04-30 17:53:43 -07:00
parent afe7ce1f12
commit 8bb1343757
7 changed files with 1468 additions and 7 deletions
+108
View File
@@ -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
* 13% → 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
+470
View File
@@ -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
+214
View File
@@ -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
+164
View File
@@ -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
}
+28 -7
View File
@@ -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"
+180
View File
@@ -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 }
}
+304
View File
@@ -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)
}