Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions frontend/src/views/swap.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
150 changes: 118 additions & 32 deletions frontend/src/views/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof estimateSwap>>;

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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<typeof setTimeout> | null = null;
let aqSeq = 0; // guards out-of-order Aquarius responses
Expand Down Expand Up @@ -300,7 +365,7 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
},
});

// ── Reverse ─────────────────────────────────────────────────────────────────
// ── Reverse ──────────────────────────────────────────────────────────────
const reverseBtn = el(
"button",
{
Expand All @@ -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, "");
Expand All @@ -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();
Expand All @@ -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" }, ["—"]);
Expand All @@ -363,12 +457,12 @@ function renderCard(ui: SwapUiState, root: HTMLElement): HTMLElement {
),
qrow(
tx("swap.brokerAdvantage", "Broker advantage"),
"How much better the brokers 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 youll 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,
),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;

Expand All @@ -488,15 +581,13 @@ 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;
setReceive(quote.status === "unfeasible" ? "No route" : "—", true);
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);
Expand All @@ -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");
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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" }, [
Expand All @@ -680,48 +769,45 @@ 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"]),
" for best-route aggregation across Stellar DEXes.",
]),
]);

// 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 })]),
Expand Down
Loading