diff --git a/frontend/e2e/tests/swap.spec.ts b/frontend/e2e/tests/swap.spec.ts
new file mode 100644
index 0000000..78cf440
--- /dev/null
+++ b/frontend/e2e/tests/swap.spec.ts
@@ -0,0 +1,60 @@
+import { expect, test } from "@playwright/test";
+
+/**
+ * Swap screen — the curated-asset picker + custom slippage (issue #318).
+ * Boots the real app under the E2E harness, navigates to Swap, and exercises:
+ * pills + Custom chip render, the searchable picker filters by code, selecting
+ * updates the pill, and custom-slippage validation flags out-of-range input.
+ * Deterministic — the search target (USDC) is in the built-in fallback list, so
+ * it passes whether or not the live LOBSTR feed is reachable.
+ */
+test.describe("Swap curated picker", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem("disclaimerAccepted", "1");
+ });
+ });
+
+ test("renders picker + custom slippage, search filters, validation works", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (e) => errors.push(String(e)));
+
+ await page.goto("/?e2e=1");
+ await page.waitForFunction(() => (window as any).__E2E__?.installed === true, null, { timeout: 30_000 });
+
+ // Navigate to Swap via the nav tab.
+ await page.getByRole("button", { name: "Swap", exact: true }).first().click();
+ await page.waitForSelector(".tl-swap__card", { timeout: 15_000 });
+
+ // Two asset pills + the Custom slippage chip exist.
+ await expect(page.locator(".tl-swap__pill")).toHaveCount(2);
+ await expect(page.getByRole("button", { name: "Custom" })).toBeVisible();
+
+ // Open the buy-side picker → search modal appears with rows.
+ await page.locator(".tl-swap__pill").nth(1).click();
+ await page.waitForSelector(".tl-ap__list .tl-ap__row", { timeout: 10_000 });
+ const before = await page.locator(".tl-ap__row").count();
+ expect(before).toBeGreaterThan(1);
+
+ // Search by code narrows the list to USDC.
+ await page.locator(".tl-ap__input").fill("usdc");
+ await page.waitForTimeout(200);
+ await expect(page.locator(".tl-ap__row").first()).toContainText("USDC");
+
+ // Pick it; pill reflects USDC; modal closes.
+ await page.locator(".tl-ap__row").first().click();
+ await expect(page.locator(".tl-ap")).toHaveCount(0);
+ await expect(page.locator(".tl-swap__pill").nth(1)).toContainText("USDC");
+
+ // Custom slippage: valid value accepted, out-of-range flagged invalid.
+ await page.getByRole("button", { name: "Custom" }).click();
+ const slipInput = page.locator(".tl-swap__slip-input");
+ await slipInput.fill("2.4");
+ await expect(slipInput).not.toHaveClass(/is-invalid/);
+ await slipInput.fill("80");
+ await expect(slipInput).toHaveClass(/is-invalid/);
+ await expect(page.locator(".tl-swap__slip-warn.is-error")).toBeVisible();
+
+ expect(errors, errors.join("\n")).toEqual([]);
+ });
+});
diff --git a/frontend/src/views/swap.css b/frontend/src/views/swap.css
index 643934d..7af69b7 100644
--- a/frontend/src/views/swap.css
+++ b/frontend/src/views/swap.css
@@ -58,6 +58,119 @@
.tl-swap__row .tl-input { flex: 1; }
.tl-swap__row .tl-select { flex: 0 0 120px; }
+/* Asset pill (opens the searchable picker) */
+.tl-swap__pill {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--tl-space-2);
+ min-height: 44px;
+ padding: var(--tl-space-2) var(--tl-space-3);
+ background: var(--tl-surface-2);
+ border: 1px solid var(--tl-border);
+ border-radius: var(--tl-radius);
+ color: var(--tl-text);
+ font-family: var(--tl-font-sans);
+ font-size: var(--tl-text-base);
+ font-weight: 600;
+ cursor: pointer;
+ transition: border-color .15s ease, background .15s ease;
+}
+.tl-swap__pill:hover { border-color: var(--tl-primary); }
+.tl-swap__pill:focus-visible { outline: 2px solid var(--tl-primary); outline-offset: 2px; }
+.tl-swap__pill-code { letter-spacing: -.2px; }
+.tl-swap__caret { color: var(--tl-text-3); font-size: var(--tl-text-xs); margin-left: var(--tl-space-1); }
+
+/* Token logo (shared by pill + picker rows) */
+.tl-asset-logo {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--tl-radius-pill);
+ overflow: hidden;
+ background: var(--tl-surface-3, var(--tl-surface-2));
+ color: var(--tl-text-2);
+ font-family: var(--tl-font-sans);
+ font-weight: 700;
+ line-height: 1;
+ text-transform: uppercase;
+}
+.tl-asset-logo__img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.tl-asset-logo--text { border: 1px solid var(--tl-border); }
+
+/* Asset picker (inside Modal) */
+.tl-ap { display: flex; flex-direction: column; gap: var(--tl-space-3); }
+.tl-ap__search { position: sticky; top: 0; }
+.tl-ap__input {
+ width: 100%;
+ box-sizing: border-box;
+ min-height: 42px;
+ padding: var(--tl-space-3) var(--tl-space-4);
+ background: var(--tl-input-bg);
+ border: 1px solid var(--tl-border);
+ border-radius: var(--tl-radius);
+ color: var(--tl-text);
+ font-family: var(--tl-font-sans);
+ font-size: var(--tl-text-base);
+}
+.tl-ap__input:focus-visible { outline: 2px solid var(--tl-primary); outline-offset: 1px; }
+.tl-ap__list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ max-height: 52vh;
+ overflow-y: auto;
+}
+.tl-ap__row {
+ display: flex;
+ align-items: center;
+ gap: var(--tl-space-3);
+ width: 100%;
+ text-align: left;
+ padding: var(--tl-space-2) var(--tl-space-3);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--tl-radius);
+ color: var(--tl-text);
+ cursor: pointer;
+}
+.tl-ap__row:hover { background: var(--tl-surface-2); }
+.tl-ap__row:focus-visible { outline: 2px solid var(--tl-primary); outline-offset: -2px; }
+.tl-ap__row.is-selected { border-color: var(--tl-primary); background: var(--tl-surface-2); }
+.tl-ap__meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.tl-ap__code {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--tl-space-2);
+ font-size: var(--tl-text-base);
+ font-weight: 700;
+ letter-spacing: -.2px;
+}
+.tl-ap__tag {
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .3px;
+ color: var(--tl-text-3);
+ border: 1px solid var(--tl-border);
+ border-radius: var(--tl-radius-xs);
+ padding: 0 4px;
+}
+.tl-ap__name {
+ font-size: var(--tl-text-xs);
+ color: var(--tl-text-3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.tl-ap__empty {
+ padding: var(--tl-space-6) var(--tl-space-4);
+ text-align: center;
+ color: var(--tl-text-3);
+ font-size: var(--tl-text-sm);
+}
+
/* Reverse pill */
.tl-swap__reverse-wrap {
display: flex;
@@ -169,6 +282,39 @@
border-color: var(--tl-primary);
}
+/* Custom slippage input */
+.tl-swap__slip-custom {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ background: var(--tl-input-bg);
+ border: 1px solid var(--tl-border);
+ border-radius: var(--tl-radius);
+ padding: 0 var(--tl-space-2);
+}
+.tl-swap__slip-input {
+ width: 48px;
+ border: none;
+ background: transparent;
+ color: var(--tl-text);
+ font-family: var(--tl-font-mono);
+ font-feature-settings: 'tnum' 1;
+ font-size: var(--tl-text-sm);
+ padding: 4px 0;
+ text-align: right;
+}
+.tl-swap__slip-input:focus { outline: none; }
+.tl-swap__slip-custom:focus-within { border-color: var(--tl-primary); }
+.tl-swap__slip-input.is-invalid { color: var(--tl-danger); }
+.tl-swap__slip-pct { color: var(--tl-text-3); font-size: var(--tl-text-sm); padding-right: 2px; }
+.tl-swap__slip-warn {
+ font-size: var(--tl-text-xs);
+ color: var(--tl-warning);
+ margin: calc(var(--tl-space-2) * -1) 0 var(--tl-space-4);
+ line-height: 1.4;
+}
+.tl-swap__slip-warn.is-error { color: var(--tl-danger); }
+
/* Footnote */
.tl-swap__foot {
font-size: var(--tl-text-xs);
diff --git a/frontend/src/views/swap.ts b/frontend/src/views/swap.ts
index 5382de8..7bdd21d 100644
--- a/frontend/src/views/swap.ts
+++ b/frontend/src/views/swap.ts
@@ -3,20 +3,15 @@ import "./swap.css";
* Swap screen — best-rate token swap routed via Stellar Broker, with a DEX Rate
* (Aquarius) cross-check.
*
- * Data wiring ported 1:1 from /tmp/old-main.ts:
- * - SWAP_ASSETS / getSwapAssetList() → the broker-id asset list (classic CODE-ISSUER)
- * - populateSwapAssets() → Select option seeding + XLM→USDC defaults
- * - fetchSwapQuote() + debounce → estimateSwap() from @stellar-broker/client
- * - compareAquariusRate() → aquariusBestRate() from ../aquarius (DEX Rate well)
- * - BROKER_TO_CONTRACT / updateSwapBalance() → fetchAssetBalance() from ../blend
+ * Asset universe comes from the LOBSTR curated feed (see ./swapAssets), not a
+ * hardcoded list: users browse or search (by code / name / domain / contract id)
+ * to pick the sell + buy assets, with token logos. Slippage offers the three
+ * presets plus a custom numeric input (0 < x ≤ 50, warned above 5%).
*
- * EXECUTE: the original never implemented an execute path — its swap button shows
- * "coming soon" and stays disabled. There is no swap-build/sign/submit XDR in
- * old-main.ts. So per the brief the quote is wired fully and execute is marked
- * TODO: the TxStepper + signAndSubmit scaffold is in place behind SWAP_EXECUTE_ENABLED
- * but the broker trade-XDR build is not yet available. See executeSwap() below.
+ * EXECUTE: the broker trade-XDR builder is still unavailable, so the action
+ * stays "coming soon" behind SWAP_EXECUTE_ENABLED — unchanged from before.
*/
-import { el, on, Button, Select, Tooltip } from "../ui";
+import { el, on, Button, Tooltip, Modal } from "../ui";
import { estimateSwap } from "@stellar-broker/client";
import { aquariusBestRate } from "../aquarius";
import { fetchAssetBalance } from "../blend";
@@ -24,6 +19,16 @@ import { getState } from "../app/state";
import { signAndSubmitClassic } from "../app/wallet";
import { toast, txShow, txStep, txHide } from "../app/chrome";
import { t } from "../i18n";
+import {
+ loadSwapAssets,
+ swapAssetsSync,
+ searchSwapAssets,
+ assetByBroker,
+ symbolForBroker,
+ contractForBroker,
+ looksLikeContractId,
+ type SwapAsset,
+} from "./swapAssets";
// ── i18n helper: fall back to the literal when the key isn't translated ───────
const tx = (key: string, fallback: string) => {
@@ -31,40 +36,20 @@ const tx = (key: string, fallback: string) => {
return v === key ? fallback : v;
};
-// ── Asset list (classic CODE-ISSUER broker IDs, not Soroban contracts) ────────
-const SWAP_ASSETS: { symbol: string; brokerId: string }[] = [
- { symbol: "XLM", brokerId: "XLM" },
- { symbol: "USDC", brokerId: "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" },
- { symbol: "EURC", brokerId: "EURC-GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2" },
- { symbol: "AQUA", brokerId: "AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA" },
- { symbol: "BLND", brokerId: "BLND-GDJEHTBE6ZHUXSWFI642DCGLUOECLHPF3KSXHPXTSTJ7E3JF6MQ5EZYY" },
- { symbol: "yXLM", brokerId: "yXLM-GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMXJTEDL5T55" },
- { symbol: "USDGLO", brokerId: "USDGLO-GBBS25EGYQPGEZCGCFBKG4OAGFXU6DSOQBGTHELLJT3HZXZJ34HWS6XV" },
-];
-
-function getSwapAssetList(): { symbol: string; brokerId: string }[] {
- return [...SWAP_ASSETS];
+// Mount a modal overlay on
; returns a closer. Mirrors app/modals.ts.
+function mountModal(node: HTMLElement | null): () => void {
+ if (!node) return () => {};
+ document.body.appendChild(node);
+ return () => node.remove();
}
-const symbolFor = (brokerId: string) =>
- SWAP_ASSETS.find((a) => a.brokerId === brokerId)?.symbol ?? brokerId;
-
-// Map broker asset ID → Soroban contract ID for balance + Aquarius lookups.
-const BROKER_TO_CONTRACT: Record = {
- XLM: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
- "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN":
- "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75",
- "EURC-GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4IBER":
- "CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV",
- "AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA":
- "CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK",
-};
-
// EXECUTE TODO: gate the on-chain execute path. The broker trade-XDR build is not
// yet available (old-main.ts left it as "coming soon"); flip on once it lands.
const SWAP_EXECUTE_ENABLED = false;
-const SLIPPAGE_CHIPS = ["0.1", "0.5", "1.0"];
+const SLIPPAGE_PRESETS = ["0.1", "0.5", "1.0"];
+const SLIP_MAX = 50; // hard cap (%)
+const SLIP_WARN = 5; // warn above this (%)
type QuoteResult = Awaited>;
@@ -72,44 +57,78 @@ interface SwapUiState {
sell: string; // broker id
buy: string; // broker id
amount: string;
- slipPct: string; // "0.1" | "0.5" | "1.0"
+ slipPct: string; // effective slippage %, e.g. "0.5" or a custom "2.4"
+ customSlip: boolean; // true when the Custom chip is selected
quote: QuoteResult | null;
}
+// ── Logo with a letter-avatar fallback ───────────────────────────────────────
+function assetLogo(a: SwapAsset, size: number): HTMLElement {
+ const wrap = el("span", {
+ class: "tl-asset-logo",
+ style: `width:${size}px;height:${size}px;font-size:${Math.round(size * 0.42)}px`,
+ });
+ const fallback = () => {
+ wrap.textContent = (a.symbol[0] ?? "?").toUpperCase();
+ wrap.classList.add("tl-asset-logo--text");
+ };
+ if (a.icon) {
+ const img = el("img", {
+ class: "tl-asset-logo__img",
+ src: a.icon,
+ alt: "",
+ loading: "lazy",
+ width: String(size),
+ height: String(size),
+ }) as HTMLImageElement;
+ on(img, "error", fallback);
+ wrap.appendChild(img);
+ } else {
+ fallback();
+ }
+ return wrap;
+}
+
/** Build the Swap view. Renders immediately; quotes are fetched async on input. */
export function swapScreen(): HTMLElement {
const root = el("div", { class: "tl-swap" });
// 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
- // trade.ts: public data shows; wallet-specific bits gate.
+ // hint and the (already quote-only) execute action gate on connect.
- const list = getSwapAssetList();
+ const list = swapAssetsSync();
const defaultSell = "XLM";
- const defaultBuy = list.find((a) => a.symbol === "USDC")?.brokerId ?? list[1].brokerId;
+ const defaultBuy =
+ list.find((a) => a.symbol === "USDC")?.brokerId ??
+ list.find((a) => a.brokerId !== defaultSell)?.brokerId ??
+ defaultSell;
const ui: SwapUiState = {
sell: defaultSell,
buy: defaultBuy,
amount: "",
slipPct: "0.5",
+ customSlip: false,
quote: null,
};
- root.replaceChildren(renderCard(ui, root));
+ root.replaceChildren(renderCard(ui));
return root;
}
// ── Card ──────────────────────────────────────────────────────────────────────
-function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
+function renderCard(ui: SwapUiState): HTMLElement {
const slip = () => Number.parseFloat(ui.slipPct) / 100; // 0.5% → 0.005
+ const slipOk = () => {
+ const n = Number.parseFloat(ui.slipPct);
+ return Number.isFinite(n) && n > 0 && n <= SLIP_MAX;
+ };
// Field refs we mutate in place (no React; imperative DOM updates).
const receiveEl = el("div", { class: "tl-swap__receive tl-swap__receive--ph" }, ["0.0"]);
const sellBalEl = el("span", { class: "tl-swap__bal" }, ["—"]);
const well = el("div", { class: "tl-swap__well", style: "display:none" });
- const slipChips: HTMLButtonElement[] = [];
let quoteTimer: ReturnType | null = null;
let aqSeq = 0; // guards out-of-order Aquarius responses
@@ -133,29 +152,123 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
onClick: () => void onAction(),
});
- // ── Asset selects ──────────────────────────────────────────────────────────
- const assetOptions = SWAP_ASSETS.map((a) => ({ value: a.brokerId, label: a.symbol }));
- const sellSelect = Select({
- options: assetOptions,
- value: ui.sell,
- width: 120,
- onChange: (v) => {
- ui.sell = v;
- ui.quote = null;
- void refreshBalance();
- scheduleQuote();
- },
- });
- const buySelect = Select({
- options: assetOptions,
- value: ui.buy,
- width: 120,
- onChange: (v) => {
- ui.buy = v;
- ui.quote = null;
- scheduleQuote();
- },
- });
+ // ── Asset pills (open the searchable picker) ────────────────────────────────
+ const sellPill = assetPill("sell");
+ const buyPill = assetPill("buy");
+
+ function assetPill(side: "sell" | "buy"): HTMLButtonElement {
+ const btn = el("button", {
+ class: "tl-swap__pill",
+ type: "button",
+ "aria-haspopup": "dialog",
+ }) as HTMLButtonElement;
+ on(btn, "click", () => openAssetPicker(side));
+ paintPill(btn, side);
+ return btn;
+ }
+
+ function paintPill(btn: HTMLButtonElement, side: "sell" | "buy"): void {
+ const brokerId = side === "sell" ? ui.sell : ui.buy;
+ const a = assetByBroker(brokerId);
+ btn.replaceChildren(
+ a ? assetLogo(a, 22) : el("span", { class: "tl-asset-logo tl-asset-logo--text", style: "width:22px;height:22px" }, ["?"]),
+ el("span", { class: "tl-swap__pill-code" }, [symbolForBroker(brokerId)]),
+ el("span", { class: "tl-swap__caret", "aria-hidden": "true" }, ["▾"]),
+ );
+ }
+
+ function repaintPills(): void {
+ paintPill(sellPill, "sell");
+ paintPill(buyPill, "buy");
+ }
+
+ // ── Searchable asset picker (modal) ─────────────────────────────────────────
+ function openAssetPicker(side: "sell" | "buy"): void {
+ let close = () => {};
+ const input = el("input", {
+ class: "tl-ap__input",
+ type: "text",
+ placeholder: tx("swap.searchAssets", "Search name, code, or contract id…"),
+ spellcheck: "false",
+ autocomplete: "off",
+ }) as HTMLInputElement;
+ const listEl = el("div", { class: "tl-ap__list" });
+
+ const renderList = (q: string) => {
+ const items = searchSwapAssets(swapAssetsSync(), q);
+ if (!items.length) {
+ listEl.replaceChildren(
+ el("div", { class: "tl-ap__empty" }, [
+ looksLikeContractId(q)
+ ? tx("swap.noContract", "That contract id isn’t in the curated list.")
+ : tx("swap.noMatch", "No assets match your search."),
+ ]),
+ );
+ return;
+ }
+ listEl.replaceChildren(
+ ...items.slice(0, 200).map((a) => assetRow(a, side, () => {
+ pickAsset(side, a.brokerId);
+ close();
+ })),
+ );
+ };
+
+ on(input, "input", () => renderList(input.value));
+ renderList("");
+
+ const node = Modal({
+ open: true,
+ title: side === "sell" ? tx("swap.selectSell", "Select asset to sell") : tx("swap.selectBuy", "Select asset to receive"),
+ width: 440,
+ onClose: () => close(),
+ children: el("div", { class: "tl-ap" }, [
+ el("div", { class: "tl-ap__search" }, [input]),
+ listEl,
+ ]),
+ });
+ close = mountModal(node);
+ input.focus();
+ // The fetch may still be resolving — refresh the list once it lands.
+ void loadSwapAssets().then(() => {
+ if (node?.isConnected) {
+ repaintPills();
+ renderList(input.value);
+ }
+ });
+ }
+
+ function assetRow(a: SwapAsset, side: "sell" | "buy", onPick: () => void): HTMLElement {
+ const otherId = side === "sell" ? ui.buy : ui.sell;
+ const selectedId = side === "sell" ? ui.sell : ui.buy;
+ const row = el("button", {
+ class: "tl-ap__row" + (a.brokerId === selectedId ? " is-selected" : ""),
+ type: "button",
+ }, [
+ assetLogo(a, 30),
+ el("span", { class: "tl-ap__meta" }, [
+ el("span", { class: "tl-ap__code" }, [a.symbol, a.brokerId === otherId ? el("span", { class: "tl-ap__tag" }, ["other side"]) : null]),
+ el("span", { class: "tl-ap__name" }, [a.name + (a.domain ? ` · ${a.domain}` : "")]),
+ ]),
+ ]) as HTMLButtonElement;
+ on(row, "click", onPick);
+ return row;
+ }
+
+ function pickAsset(side: "sell" | "buy", brokerId: string): void {
+ // Picking the asset already on the other side swaps the pair (no dupes).
+ if (side === "sell") {
+ if (brokerId === ui.buy) ui.buy = ui.sell;
+ ui.sell = brokerId;
+ } else {
+ if (brokerId === ui.sell) ui.sell = ui.buy;
+ ui.buy = brokerId;
+ }
+ ui.quote = null;
+ repaintPills();
+ void refreshBalance();
+ scheduleQuote();
+ }
// ── Reverse ─────────────────────────────────────────────────────────────────
const reverseBtn = el(
@@ -167,9 +280,8 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
const tmp = ui.sell;
ui.sell = ui.buy;
ui.buy = tmp;
- sellSelect.value = ui.sell;
- buySelect.value = ui.buy;
ui.quote = null;
+ repaintPills();
void refreshBalance();
scheduleQuote();
});
@@ -184,23 +296,75 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
scheduleQuote();
});
- // ── 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;
+ // ── Slippage: presets + Custom ──────────────────────────────────────────────
+ const slipChips: HTMLButtonElement[] = [];
+ const customChip = el("button", { class: "tl-swap__chip", type: "button" }, [tx("swap.custom", "Custom")]) as HTMLButtonElement;
+ const customInput = el("input", {
+ class: "tl-swap__slip-input",
+ type: "text",
+ inputmode: "decimal",
+ placeholder: "2.5",
+ "aria-label": tx("swap.customSlippage", "Custom slippage %"),
+ }) as HTMLInputElement;
+ const customPctSign = el("span", { class: "tl-swap__slip-pct" }, ["%"]);
+ const customWrap = el("div", { class: "tl-swap__slip-custom", style: "display:none" }, [customInput, customPctSign]);
+ const slipWarn = el("div", { class: "tl-swap__slip-warn", style: "display:none" }, []);
+
+ for (const s of SLIPPAGE_PRESETS) {
+ const chip = el("button", { class: "tl-swap__chip", type: "button" }, [`${s}%`]) as HTMLButtonElement;
on(chip, "click", () => {
+ ui.customSlip = false;
ui.slipPct = s;
- slipChips.forEach((c) => c.classList.toggle("is-active", c === chip));
- updateWellSlip();
+ customWrap.style.display = "none";
+ paintSlip();
ui.quote = null;
scheduleQuote();
});
slipChips.push(chip);
}
+ on(customChip, "click", () => {
+ ui.customSlip = true;
+ if (!customInput.value) customInput.value = ui.slipPct;
+ ui.slipPct = customInput.value;
+ customWrap.style.display = "";
+ paintSlip();
+ customInput.focus();
+ customInput.select();
+ ui.quote = null;
+ scheduleQuote();
+ });
+
+ on(customInput, "input", () => {
+ const cleaned = customInput.value.replace(/[^\d.]/g, "");
+ if (cleaned !== customInput.value) customInput.value = cleaned;
+ ui.slipPct = cleaned;
+ ui.quote = null;
+ paintSlip();
+ scheduleQuote();
+ });
+
+ function paintSlip(): void {
+ slipChips.forEach((c, i) => c.classList.toggle("is-active", !ui.customSlip && ui.slipPct === SLIPPAGE_PRESETS[i]));
+ customChip.classList.toggle("is-active", ui.customSlip);
+ const n = Number.parseFloat(ui.slipPct);
+ let warn = "";
+ let bad = false;
+ if (ui.customSlip) {
+ if (!ui.slipPct || !Number.isFinite(n) || n <= 0 || n > SLIP_MAX) {
+ bad = true;
+ warn = tx("swap.slipRange", `Enter a slippage between 0 and ${SLIP_MAX}%.`);
+ } else if (n > SLIP_WARN) {
+ warn = tx("swap.slipHigh", "High slippage — you may receive significantly less than quoted.");
+ }
+ }
+ customInput.classList.toggle("is-invalid", bad);
+ slipWarn.textContent = warn;
+ slipWarn.classList.toggle("is-error", bad);
+ slipWarn.style.display = warn ? "" : "none";
+ updateWellSlip();
+ }
+
// ── Quote well rows ──────────────────────────────────────────────────────────
const rateVal = el("span", { class: "tl-swap__qrow-v" }, ["—"]);
const dexVal = el("span", { class: "tl-swap__qrow-v" }, ["—"]);
@@ -208,64 +372,43 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
const slipVal = el("span", { class: "tl-swap__qrow-v" }, [`${ui.slipPct}%`]);
well.replaceChildren(
- qrow(
- tx("swap.rate", "Rate"),
- "The effective price you get for this swap via the best routed path.",
- rateVal,
- ),
- qrow(
- "DEX Rate",
- "For comparison: the direct quote on the Stellar DEX (Aquarius), without broker routing.",
- dexVal,
- true,
- ),
- qrow(
- tx("swap.brokerAdvantage", "Broker advantage"),
- "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.",
- slipVal,
- true,
- ),
+ qrow(tx("swap.rate", "Rate"), "The effective price you get for this swap via the best routed path.", rateVal),
+ qrow("DEX Rate", "For comparison: the direct quote on the Stellar DEX (Aquarius), without broker routing.", dexVal, true),
+ qrow(tx("swap.brokerAdvantage", "Broker advantage"), "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.", slipVal, true),
);
function updateWellSlip() {
- slipVal.textContent = `${ui.slipPct}%`;
+ slipVal.textContent = `${ui.slipPct || "0"}%`;
}
// ── Balance ──────────────────────────────────────────────────────────────────
async function refreshBalance() {
const addr = getState().userAddress;
- const contractId = BROKER_TO_CONTRACT[ui.sell];
+ const contractId = contractForBroker(ui.sell);
if (!addr || !contractId) {
sellBalEl.textContent = "—";
return;
}
try {
const bal = await fetchAssetBalance(addr, contractId);
- sellBalEl.textContent = `${bal.toLocaleString("en-US", { maximumFractionDigits: 4 })} ${symbolFor(ui.sell)}`;
+ sellBalEl.textContent = `${bal.toLocaleString("en-US", { maximumFractionDigits: 4 })} ${symbolForBroker(ui.sell)}`;
} catch {
sellBalEl.textContent = "—";
}
}
- // ── Button state machine (ported from updateSwapBtn) ─────────────────────────
+ // ── Button state machine ─────────────────────────────────────────────────────
function updateButton() {
const hasAmount = !!ui.amount && Number.parseFloat(ui.amount) > 0;
const samePair = ui.sell === ui.buy;
- // Get Quote works disconnected (estimateSwap needs no wallet). Only the
- // execute action gates on connect, and it's already quote-only for now.
if (samePair) {
setBtn(tx("swap.selectDifferent", "Select a different pair"), true);
} else if (!hasAmount) {
setBtn(tx("swap.enterAmount", "Enter an amount"), true);
+ } else if (ui.customSlip && !slipOk()) {
+ setBtn(tx("swap.slipRange", `Enter a slippage between 0 and ${SLIP_MAX}%.`), true);
} else if (ui.quote && ui.quote.status === "success") {
- // EXECUTE TODO: enable only once the broker trade-XDR build lands.
- // When disconnected, prompt to connect before executing (execute is still
- // disabled today regardless, so this is forward-looking).
if (!getState().userAddress) {
setBtn(tx("nav.connect", "Connect Wallet"), true);
} else {
@@ -292,7 +435,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
async function fetchQuote() {
const { sell, buy, amount } = ui;
- if (!amount || Number.parseFloat(amount) <= 0 || sell === buy) {
+ if (!amount || Number.parseFloat(amount) <= 0 || sell === buy || !slipOk()) {
ui.quote = null;
hideWell();
setReceive("0.0", true);
@@ -313,8 +456,8 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
if (quote.status === "success" && quote.estimatedBuyingAmount) {
const sellNum = Number.parseFloat(amount);
const buyNum = Number.parseFloat(quote.estimatedBuyingAmount);
- const sellSym = symbolFor(sell);
- const buySym = symbolFor(buy);
+ const sellSym = symbolForBroker(sell);
+ const buySym = symbolForBroker(buy);
setReceive(buyNum.toLocaleString("en-US", { maximumFractionDigits: 4 }), false);
rateVal.textContent = `1 ${sellSym} ≈ ${(buyNum / sellNum).toLocaleString("en-US", { maximumFractionDigits: 6 })} ${buySym}`;
@@ -322,7 +465,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;
@@ -330,7 +472,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);
@@ -341,15 +482,10 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
}
// ── DEX (Aquarius) comparison ────────────────────────────────────────────────
- async function compareDexRate(
- sellBrokerId: string,
- buyBrokerId: string,
- sellNum: number,
- buySym: string,
- ) {
+ async function compareDexRate(sellBrokerId: string, buyBrokerId: string, sellNum: number, buySym: string) {
const seq = ++aqSeq;
- const sellC = BROKER_TO_CONTRACT[sellBrokerId];
- const buyC = BROKER_TO_CONTRACT[buyBrokerId];
+ const sellC = contractForBroker(sellBrokerId);
+ const buyC = contractForBroker(buyBrokerId);
if (!sellC || !buyC) {
if (seq === aqSeq) {
dexVal.textContent = tx("common.na", "N/A");
@@ -384,7 +520,6 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
// ── Primary action: Get Quote OR (TODO) execute ──────────────────────────────
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();
@@ -394,11 +529,9 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
}
// ── EXECUTE (TODO) ───────────────────────────────────────────────────────────
- // old-main.ts never built a broker trade-XDR — the button was permanently
- // "coming soon". The TxStepper + sign/submit scaffold below is ready; the
- // missing piece is a broker (or Aquarius router) trade-XDR builder that returns
- // a signable transaction. Until SWAP_EXECUTE_ENABLED is flipped on with a real
- // builder, this is unreachable (button stays disabled on a success quote).
+ // The broker trade-XDR builder is not available yet; the button stays disabled
+ // on a success quote until SWAP_EXECUTE_ENABLED is flipped on with a real
+ // builder. The TxStepper + sign/submit scaffold below is ready for that.
async function executeSwap() {
if (!SWAP_EXECUTE_ENABLED) {
toast(tx("swap.comingSoon", "Swap execution is coming soon."), "info");
@@ -416,17 +549,14 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
txShow(steps);
try {
txStep(0);
- // TODO: build the broker trade XDR for ui.quote here.
- // const xdr = await buildBrokerTradeXdr(ui.quote, addr);
const xdr = ""; // placeholder — no builder available yet
if (!xdr) throw new Error("Swap execution not yet implemented");
txStep(1);
- const hash = await signAndSubmitClassic(xdr, `Swap ${symbolFor(ui.sell)} → ${symbolFor(ui.buy)}`);
+ const hash = await signAndSubmitClassic(xdr, `Swap ${symbolForBroker(ui.sell)} → ${symbolForBroker(ui.buy)}`);
txStep(steps.length);
toast(tx("swap.done", "Swap submitted."), "success", hash);
txHide();
- // Refresh the sell-side balance after a successful swap.
void refreshBalance();
} catch (e) {
txStep(steps.length - 1, true);
@@ -442,9 +572,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
el("h2", { class: "tl-swap__title" }, [tx("swap.title", "Swap")]),
el("span", { class: "tl-swap__via" }, [
tx("swap.via", "via Stellar Broker"),
- Tooltip({
- text: "Swaps route through Stellar Broker, which aggregates the best path across Stellar DEXes (including Aquarius) for the best execution.",
- }),
+ Tooltip({ text: "Swaps route through Stellar Broker, which aggregates the best path across Stellar DEXes (including Aquarius) for the best execution." }),
]),
]),
@@ -453,23 +581,26 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
el("label", { class: "tl-swap__label" }, [tx("swap.youSell", "You sell")]),
sellBalEl,
]),
- el("div", { class: "tl-swap__row" }, [amountField, sellSelect]),
+ el("div", { class: "tl-swap__row" }, [amountField, sellPill]),
// 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]),
+ el("div", { class: "tl-swap__row" }, [receiveEl, buyPill]),
// Quote well
well,
- // Slippage chips
+ // Slippage presets + Custom
el("div", { class: "tl-swap__slip" }, [
el("span", { class: "tl-swap__slip-label" }, [tx("swap.slippageShort", "Slippage")]),
...slipChips,
+ customChip,
+ customWrap,
]),
+ slipWarn,
// Primary action
actionBtn,
@@ -478,13 +609,18 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
el("p", { class: "tl-swap__foot" }, [
"Swaps are executed through ",
el("strong", {}, ["Stellar Broker"]),
- " for best-route aggregation across Stellar DEXes.",
+ " for best-route aggregation across Stellar DEXes. Asset list curated by ",
+ el("strong", {}, ["LOBSTR"]),
+ ".",
]),
]);
// Initial async fills.
+ paintSlip();
void refreshBalance();
updateButton();
+ // Pull the curated list; repaint pills (logos) once it lands.
+ void loadSwapAssets().then(() => repaintPills());
return el("div", { class: "tl-swap__wrap" }, [card]);
}
diff --git a/frontend/src/views/swapAssets.ts b/frontend/src/views/swapAssets.ts
new file mode 100644
index 0000000..0902702
--- /dev/null
+++ b/frontend/src/views/swapAssets.ts
@@ -0,0 +1,204 @@
+/**
+ * Swap asset universe — sourced from the LOBSTR curated assets feed instead of
+ * a hardcoded list. One entry yields everything the swap screen needs:
+ * - brokerId ("CODE-ISSUER", or "XLM") → estimateSwap()
+ * - contractId (C… StrKey) → fetchAssetBalance / aquariusBestRate
+ * - icon / name / domain / decimals → the asset picker UI
+ *
+ * The feed (`curated.json`) sends a 32-byte contract as raw hex; we encode it to
+ * the canonical C… StrKey. XLM is native and absent from the feed, so we inject
+ * a synthetic entry. On any fetch failure we fall back to a small built-in list
+ * so the screen never dies.
+ */
+import { StrKey } from "@stellar/stellar-sdk";
+
+export interface SwapAsset {
+ symbol: string; // asset code, e.g. "USDC"
+ name: string; // human name, e.g. "USD Coin"
+ domain: string; // home domain, e.g. "centre.io" ("" if unknown)
+ icon: string; // logo URL ("" if none)
+ decimals: number;
+ issuer: string; // G… issuer ("" for native XLM)
+ brokerId: string; // "XLM" or "CODE-ISSUER" — the Stellar Broker asset id
+ contractId: string; // C… StrKey Soroban contract id
+}
+
+const CURATED_URL = "https://lobstr.co/api/v1/sep/assets/curated.json";
+const XLM_CONTRACT = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA";
+const CACHE_KEY = "swapCuratedAssets:v1";
+const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h
+
+/** Synthetic native entry — XLM is not in the curated feed. */
+const XLM: SwapAsset = {
+ symbol: "XLM",
+ name: "Stellar Lumens",
+ domain: "stellar.org",
+ icon: "",
+ decimals: 7,
+ issuer: "",
+ brokerId: "XLM",
+ contractId: XLM_CONTRACT,
+};
+
+/** Last-resort list if the curated feed can't be reached (degraded, but usable). */
+const FALLBACK: SwapAsset[] = [
+ XLM,
+ mk("USDC", "USD Coin", "centre.io", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"),
+ mk("EURC", "Euro Coin", "centre.io", "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2", "CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV"),
+ mk("AQUA", "Aquarius", "aqua.network", "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", "CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK"),
+];
+
+function mk(symbol: string, name: string, domain: string, issuer: string, contractId: string): SwapAsset {
+ return { symbol, name, domain, icon: "", decimals: 7, issuer, brokerId: `${symbol}-${issuer}`, contractId };
+}
+
+let cache: SwapAsset[] | null = null;
+let inflight: Promise | null = null;
+
+/** hex(32 bytes) → C… StrKey contract id, or null if malformed. */
+function hexToContractId(hex: string): string | null {
+ if (!/^[0-9a-fA-F]{64}$/.test(hex)) return null;
+ const bytes = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+ try {
+ return StrKey.encodeContract(bytes);
+ } catch {
+ return null;
+ }
+}
+
+interface CuratedEntry {
+ contract?: string;
+ code?: string;
+ issuer?: string;
+ name?: string;
+ domain?: string;
+ icon?: string;
+ decimals?: number;
+}
+
+function mapEntry(a: CuratedEntry): SwapAsset | null {
+ if (!a.code || !a.issuer || !a.contract) return null;
+ const contractId = hexToContractId(a.contract);
+ if (!contractId) return null;
+ return {
+ symbol: a.code,
+ name: a.name || a.code,
+ domain: a.domain || "",
+ icon: a.icon || "",
+ decimals: typeof a.decimals === "number" ? a.decimals : 7,
+ issuer: a.issuer,
+ brokerId: `${a.code}-${a.issuer}`,
+ contractId,
+ };
+}
+
+function dedupSorted(list: SwapAsset[]): SwapAsset[] {
+ const seen = new Set();
+ const out: SwapAsset[] = [];
+ for (const a of list) {
+ if (seen.has(a.brokerId)) continue;
+ seen.add(a.brokerId);
+ out.push(a);
+ }
+ out.sort((x, y) => x.symbol.localeCompare(y.symbol));
+ return out;
+}
+
+function readSession(): SwapAsset[] | null {
+ try {
+ const raw = sessionStorage.getItem(CACHE_KEY);
+ if (!raw) return null;
+ const { ts, list } = JSON.parse(raw) as { ts: number; list: SwapAsset[] };
+ if (!Array.isArray(list) || !list.length) return null;
+ if (Date.now() - ts > CACHE_TTL_MS) return null;
+ return list;
+ } catch {
+ return null;
+ }
+}
+
+function writeSession(list: SwapAsset[]): void {
+ try {
+ sessionStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), list }));
+ } catch {
+ /* quota / private mode — non-fatal */
+ }
+}
+
+/** Fetch (once) + cache the curated asset list. Falls back on any failure. */
+export async function loadSwapAssets(): Promise {
+ if (cache) return cache;
+ const cached = readSession();
+ if (cached) {
+ cache = cached;
+ return cached;
+ }
+ if (inflight) return inflight;
+ inflight = (async () => {
+ try {
+ const res = await fetch(CURATED_URL);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const json = (await res.json()) as { assets?: CuratedEntry[] };
+ const mapped = (json.assets ?? [])
+ .map(mapEntry)
+ .filter((a): a is SwapAsset => a !== null);
+ if (!mapped.length) throw new Error("empty curated list");
+ const list = [XLM, ...dedupSorted(mapped)];
+ cache = list;
+ writeSession(list);
+ return list;
+ } catch (e) {
+ console.warn("Swap: curated asset fetch failed, using fallback —", e instanceof Error ? e.message : e);
+ cache = FALLBACK;
+ return FALLBACK;
+ } finally {
+ inflight = null;
+ }
+ })();
+ return inflight;
+}
+
+/** Whatever's loaded so far — fallback list until the fetch resolves. */
+export function swapAssetsSync(): SwapAsset[] {
+ return cache ?? FALLBACK;
+}
+
+export function assetByBroker(brokerId: string): SwapAsset | undefined {
+ return swapAssetsSync().find((a) => a.brokerId === brokerId);
+}
+
+export function symbolForBroker(brokerId: string): string {
+ return assetByBroker(brokerId)?.symbol ?? brokerId;
+}
+
+export function contractForBroker(brokerId: string): string | undefined {
+ return assetByBroker(brokerId)?.contractId;
+}
+
+/**
+ * Filter the list by a free-text query. Matches code / name / domain / issuer
+ * and the contract id — accepting both the C… StrKey and the raw 64-hex form.
+ */
+export function searchSwapAssets(list: SwapAsset[], query: string): SwapAsset[] {
+ const s = query.trim().toLowerCase();
+ if (!s) return list;
+ // If the query is raw hex for a contract, compare against the encoded C… id.
+ const asContract = /^[0-9a-f]{64}$/.test(s) ? hexToContractId(s)?.toLowerCase() : undefined;
+ return list.filter((a) => {
+ if (asContract && a.contractId.toLowerCase() === asContract) return true;
+ return (
+ a.symbol.toLowerCase().includes(s) ||
+ a.name.toLowerCase().includes(s) ||
+ a.domain.toLowerCase().includes(s) ||
+ a.issuer.toLowerCase().includes(s) ||
+ a.contractId.toLowerCase().includes(s)
+ );
+ });
+}
+
+/** True when the query looks like a contract id the user pasted (C… or hex). */
+export function looksLikeContractId(query: string): boolean {
+ const s = query.trim();
+ return /^C[A-Z2-7]{55}$/.test(s) || /^[0-9a-fA-F]{64}$/.test(s);
+}