diff --git a/frontend/src/views/swap.css b/frontend/src/views/swap.css index 06974a1..5edd4d2 100644 --- a/frontend/src/views/swap.css +++ b/frontend/src/views/swap.css @@ -246,3 +246,42 @@ padding: var(--tl-space-4); } } +.tl-swap__asset-search { + width: 100%; + margin-bottom: var(--tl-space-3); +} + +.tl-swap__asset-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px; + cursor: pointer; +} + +.tl-swap__asset-logo { + width: 28px; + height: 28px; + border-radius: 999px; +} + +.tl-swap__asset-avatar { + width: 28px; + height: 28px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + background: var(--tl-surface-2); +} + +.tl-swap__custom-slip { + width: 100px; +} + +.tl-swap__slip-warning { + margin-top: 8px; + color: var(--tl-warning); + font-size: var(--tl-text-sm); +} diff --git a/frontend/src/views/swap.ts b/frontend/src/views/swap.ts index dc2cdf2..66eb111 100644 --- a/frontend/src/views/swap.ts +++ b/frontend/src/views/swap.ts @@ -63,7 +63,12 @@ function createBrokerClient(): StellarBrokerClient { // Hard stop for a broker trading session that never finishes. const TRADE_TIMEOUT_MS = 180_000; -const SLIPPAGE_CHIPS = ["0.1", "0.5", "1.0"]; +const SLIPPAGE_CHIPS = [ + "0.1", + "0.5", + "1.0", + "custom", +]; type QuoteResult = Awaited>; @@ -205,17 +210,58 @@ function runBrokerTrade( } interface SwapUiState { - sell: string; // broker id - buy: string; // broker id + sell: string; + buy: string; amount: string; - slipPct: string; // "0.1" | "0.5" | "1.0" + slipPct: string; // "0.1" | "0.5" | "1.0" or custom numeric string quote: QuoteResult | null; } +let assetsLoaded = false; + /** Build the Swap view. Renders immediately; quotes are fetched async on input. */ export function swapScreen(): HTMLElement { const root = el("div", { class: "tl-swap" }); + if (!assetsLoaded) { + root.replaceChildren( + el("div", { class: "tl-swap__loading" }, [ + "Loading assets...", + ]), + ); + + void loadSwapAssets() + .then((assets) => { + SWAP_ASSETS = assets; + assetsLoaded = true; + + root.replaceChildren(renderCard({ + sell: + assets.find((a) => a.symbol === "XLM")?.brokerId ?? + assets[0]?.brokerId ?? + "XLM", + buy: + assets.find((a) => a.symbol === "USDC")?.brokerId ?? + assets[1]?.brokerId ?? + assets[0].brokerId, + amount: "", + slipPct: "0.5", + quote: null, + }, root)); + }) + .catch((err) => { + console.error("Failed to load swap assets", err); + + root.replaceChildren( + el("div", { class: "tl-swap__error" }, [ + "Failed to load assets", + ]), + ); + }); + + return root; + } + // The quote form renders and works whether or not a wallet is connected — // estimateSwap / aquariusBestRate need no address. Only the sell-side balance // hint and the (already quote-only) execute action gate on connect. Mirrors @@ -234,10 +280,11 @@ export function swapScreen(): HTMLElement { }; root.replaceChildren(renderCard(ui, root)); + return root; } -// ── Card ────────────────────────────────────────────────────────────────────── +// ── Card ──────────────────────────────────────────────────────────────────── function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { const slip = () => Number.parseFloat(ui.slipPct) / 100; // 0.5% → 0.005 @@ -246,6 +293,24 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { const sellBalEl = el("span", { class: "tl-swap__bal" }, ["—"]); const well = el("div", { class: "tl-swap__well", style: "display:none" }); const slipChips: HTMLButtonElement[] = []; + const customSlipInput = el("input", { + class: "tl-swap__custom-slip", + type: "number", + min: "0.01", + max: "50", + step: "0.1", + placeholder: "Custom %", + style: "display:none", + }) as HTMLInputElement; + + const slipWarning = el( + "div", + { + class: "tl-swap__slip-warning", + style: "display:none", + }, + ["High slippage warning (>5%)"], + ); let quoteTimer: ReturnType | null = null; let aqSeq = 0; // guards out-of-order Aquarius responses @@ -300,7 +365,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { }, }); - // ── Reverse ───────────────────────────────────────────────────────────────── + // ── Reverse ────────────────────────────────────────────────────────────── const reverseBtn = el( "button", { @@ -322,7 +387,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { scheduleQuote(); }); - // ── Amount input ───────────────────────────────────────────────────────────── + // ── Amount input ────────────────────────────────────────────────────────── on(amountInput, "input", () => { // Strip everything that isn't a digit or a dot (matches old-main.ts). const cleaned = amountInput.value.replace(/[^\d.]/g, ""); @@ -332,12 +397,19 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { scheduleQuote(); }); - // ── Slippage chips ────────────────────────────────────────────────────────── + // ── Slippage chips ──────────────────────────────────────────────────────── for (const s of SLIPPAGE_CHIPS) { const chip = el("button", { class: `tl-swap__chip${s === ui.slipPct ? " is-active" : ""}`, type: "button" }, [ `${s}%`, ]) as HTMLButtonElement; on(chip, "click", () => { + if (s === "custom") { + customSlipInput.style.display = ""; + customSlipInput.focus(); + return; + } + + customSlipInput.style.display = "none"; ui.slipPct = s; slipChips.forEach((c) => c.classList.toggle("is-active", c === chip)); updateWellSlip(); @@ -347,7 +419,29 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { slipChips.push(chip); } - // ── Quote well rows ────────────────────────────────────────────────────────── + on(customSlipInput, "input", () => { + const value = Number(customSlipInput.value); + + if ( + Number.isNaN(value) || + value <= 0 || + value > 50 + ) { + return; + } + + ui.slipPct = String(value); + + slipWarning.style.display = + value > 5 ? "" : "none"; + + updateWellSlip(); + + ui.quote = null; + scheduleQuote(); + }); + + // ── Quote well rows ─────────────────────────────────────────────────────── const rateVal = el("span", { class: "tl-swap__qrow-v" }, ["—"]); const dexVal = el("span", { class: "tl-swap__qrow-v" }, ["—"]); const advVal = el("span", { class: "tl-swap__qrow-v tl-swap__qrow-v--good" }, ["—"]); @@ -363,12 +457,12 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { ), qrow( tx("swap.brokerAdvantage", "Broker advantage"), - "How much better the broker’s routed rate is versus a direct DEX trade.", + "How much better the broker's routed rate is versus a direct DEX trade.", advVal, ), qrow( tx("swap.slippage", "Slippage tolerance"), - "The maximum price movement you’ll accept before the swap fails. Lower is stricter but more likely to revert in volatile markets.", + "The maximum price movement you'll accept before the swap fails. Lower is stricter but more likely to revert in volatile markets.", slipVal, true, ), @@ -417,7 +511,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { } } - // ── Button state machine (ported from updateSwapBtn) ───────────────────────── + // ── Button state machine ────────────────────────────────────────────────── function updateButton() { if (executing) { setBtn(tx("swap.executing", "Swapping…"), true); @@ -448,7 +542,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { actionBtn.disabled = disabled; } - // ── Quote fetch (estimateSwap) ─────────────────────────────────────────────── + // ── Quote fetch ──────────────────────────────────────────────────────────── function scheduleQuote() { updateButton(); if (quoteTimer) clearTimeout(quoteTimer); @@ -471,7 +565,6 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { sellingAmount: amount, slippageTolerance: slip(), }); - // Discard if inputs changed while the request was in flight. if (ui.sell !== sell || ui.buy !== buy || ui.amount !== amount) return; ui.quote = quote; @@ -488,7 +581,6 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { ? `+${Number.parseFloat(quote.profit).toLocaleString("en-US", { maximumFractionDigits: 4 })} ${buySym}` : "—"; showWell(); - // Cross-check against the DEX (Aquarius) and fill the DEX Rate row. void compareDexRate(sell, buy, sellNum, buySym); } else { ui.quote = null; @@ -496,7 +588,6 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { hideWell(); } } catch (e) { - // Discard stale failures too. if (ui.sell !== sell || ui.buy !== buy || ui.amount !== amount) return; ui.quote = null; setReceive("Quote unavailable", true); @@ -520,7 +611,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { } const amountStroops = BigInt(Math.round(sellNum * 1e7)); const aq = await aquariusBestRate(sellC, buyC, amountStroops); - if (seq !== aqSeq) return; // a newer quote superseded this one + if (seq !== aqSeq) return; if (!aq) { dexVal.textContent = tx("common.unavailable", "unavailable"); dexVal.removeAttribute("title"); @@ -531,7 +622,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { dexVal.removeAttribute("title"); } - // ── Receive / well helpers ─────────────────────────────────────────────────── + // ── Receive / well helpers ─────────────────────────────────────────────── function setReceive(text: string, placeholder: boolean) { receiveEl.textContent = text; receiveEl.classList.toggle("tl-swap__receive--ph", placeholder); @@ -543,9 +634,8 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { well.style.display = "none"; } - // ── Primary action: Get Quote OR (TODO) execute ────────────────────────────── + // ── Primary action ───────────────────────────────────────────────────────── async function onAction() { - // If we have no live success quote yet, the click means "Get Quote". if (!ui.quote || ui.quote.status !== "success") { if (quoteTimer) clearTimeout(quoteTimer); await fetchQuote(); @@ -667,9 +757,8 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { } } - // ── Layout ──────────────────────────────────────────────────────────────────── + // ── Layout ───────────────────────────────────────────────────────────────── const card = el("div", { class: "tl-swap__card" }, [ - // Header el("div", { class: "tl-swap__head" }, [ el("h2", { class: "tl-swap__title" }, [tx("swap.title", "Swap")]), el("span", { class: "tl-swap__via" }, [ @@ -680,33 +769,31 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { ]), ]), - // You sell el("div", { class: "tl-swap__label-row" }, [ el("label", { class: "tl-swap__label" }, [tx("swap.youSell", "You sell")]), sellBalEl, ]), el("div", { class: "tl-swap__row" }, [amountField, sellSelect]), - // Reverse el("div", { class: "tl-swap__reverse-wrap" }, [reverseBtn]), - // You receive el("label", { class: "tl-swap__label" }, [tx("swap.youReceive", "You receive (estimated)")]), el("div", { class: "tl-swap__row" }, [receiveEl, buySelect]), - // Quote well well, - // Slippage chips el("div", { class: "tl-swap__slip" }, [ - el("span", { class: "tl-swap__slip-label" }, [tx("swap.slippageShort", "Slippage")]), + el("span", { class: "tl-swap__slip-label" }, [ + tx("swap.slippageShort", "Slippage"), + ]), ...slipChips, + customSlipInput, ]), - // Primary action + slipWarning, + actionBtn, - // Footnote el("p", { class: "tl-swap__foot" }, [ "Swaps are executed through ", el("strong", {}, ["Stellar Broker"]), @@ -714,14 +801,13 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement { ]), ]); - // Initial async fills. void refreshBalance(); updateButton(); return el("div", { class: "tl-swap__wrap" }, [card]); } -// ── Quote-well row ────────────────────────────────────────────────────────────── +// ── Quote-well row ──────────────────────────────────────────────────────── function qrow(label: string, tip: string, valueEl: HTMLElement, dim = false): HTMLElement { return el("div", { class: `tl-swap__qrow${dim ? " tl-swap__qrow--dim" : ""}` }, [ el("span", { class: "tl-swap__qrow-k" }, [label, Tooltip({ text: tip })]), diff --git a/frontend/src/views/swapAssets.ts b/frontend/src/views/swapAssets.ts new file mode 100644 index 0000000..4562655 --- /dev/null +++ b/frontend/src/views/swapAssets.ts @@ -0,0 +1,134 @@ +import { StrKey } from "@stellar/stellar-sdk"; + +export interface SwapAsset { + symbol: string; + name: string; + domain: string; + icon?: string; + decimals?: number; + issuer?: string; + brokerId: string; + contractId: string; + rawContract?: string; +} + +const CURATED_URL = + "https://lobstr.co/api/v1/sep/assets/curated.json"; + +const CACHE_KEY = "turbolong.swap.assets"; +const TTL_MS = 30_000; + +let memoryCache: + | { + ts: number; + assets: SwapAsset[]; + } + | null = null; + +const FALLBACK_ASSETS: SwapAsset[] = [ + { + symbol: "XLM", + name: "Stellar Lumens", + domain: "stellar.org", + brokerId: "XLM", + contractId: + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + }, + { + symbol: "USDC", + brokerId: + "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + contractId: + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + name: "USD Coin", + domain: "circle.com", + }, +]; + +function contractHexToStrKey(hex: string): string { + return StrKey.encodeContract(Buffer.from(hex, "hex")); +} + +function createXlmAsset(): SwapAsset { + return { + symbol: "XLM", + name: "Stellar Lumens", + domain: "stellar.org", + brokerId: "XLM", + contractId: + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + }; +} + +export async function loadSwapAssets(): Promise { + const now = Date.now(); + + if (memoryCache && now - memoryCache.ts < TTL_MS) { + return memoryCache.assets; + } + + try { + const cached = sessionStorage.getItem(CACHE_KEY); + + if (cached) { + const parsed = JSON.parse(cached); + + if (now - parsed.ts < TTL_MS) { + memoryCache = parsed; + return parsed.assets; + } + } + + const res = await fetch(CURATED_URL); + + if (!res.ok) { + throw new Error("Failed curated fetch"); + } + + const json = await res.json(); + + const assets: SwapAsset[] = [ + createXlmAsset(), + ...json.assets.map((asset: any) => ({ + symbol: asset.code, + name: asset.name, + domain: asset.domain, + icon: asset.icon, + decimals: asset.decimals, + issuer: asset.issuer, + brokerId: `${asset.code}-${asset.issuer}`, + contractId: contractHexToStrKey(asset.contract), + rawContract: asset.contract, + })), + ]; + + const payload = { + ts: now, + assets, + }; + + memoryCache = payload; + sessionStorage.setItem(CACHE_KEY, JSON.stringify(payload)); + + return assets; + } catch { + return FALLBACK_ASSETS; + } +} + +export function findAsset( + assets: SwapAsset[], + query: string, +): SwapAsset | undefined { + const q = query.trim().toLowerCase(); + + return assets.find((a) => { + return ( + a.symbol.toLowerCase().includes(q) || + a.name.toLowerCase().includes(q) || + a.domain.toLowerCase().includes(q) || + a.contractId.toLowerCase() === q || + a.rawContract?.toLowerCase() === q + ); + }); +}