feat(frontend): honor Swap query params on / and /swap (Uniswap/Pancake/Terra DEX aliases) #711

Closed
opened 2026-08-31 04:56:51 +00:00 by PlasticDigits · 18 comments
PlasticDigits commented 2026-08-31 04:56:51 +00:00 (Migrated from gitlab.com)

Summary

The dApp homepage (/ / Swap) ignores common DEX share and aggregator query strings (?outputCurrency=, inputCurrency, from, to, and the usual Uniswap / PancakeSwap / Terra Classic DEX aliases). https://dex.cl8y.com/swap/?… is supposed to work as well (redirect to / is OK if the query string is preserved). Today those links land on the default retail pair and look like the tokens were never requested.

This is one product surface: Swap deep-link query params (route alias + parser + apply). Bundle /swap preservation and token/amount apply together. Do not split “redirect” vs “parse” into two issues.

Current codebase

Swap lives at /; /swap is not a real route

frontend-dapp/src/App.tsx mounts SwapPage only at /. There is no /swap Route. Unknown paths hit:

<Route path="*" element={<Navigate to="/" replace />} />

React Router Navigate to="/" drops location.search and location.hash. Visiting https://dex.cl8y.com/swap/?from=uluna&to=<cw20> therefore:

  1. Nginx SPA fallback still serves index.html (docker/frontend/nginx.conf try_files … /index.html) — Coolify does not 404 the path.
  2. The client then replaces the URL with / and throws away the query.
  3. SwapPage never sees the intended tokens.

Playwright already page.goto('/swap') (e2e/swap-token-select-viewport.spec.ts, e2e/swap-token-select-cls.spec.ts) and comments that /swap redirects to /. That is why E2E still “works” without query params. Legal clickwrap allowlists https://dex.cl8y.com/swap (legalClickwrap.test.ts) as a same-origin path, not as a Swap parser.

Shell Swap tab is { path: '/', label: 'Swap', end: true } (navItems.ts). Remaining on / after a search-preserving redirect keeps the Swap tab active (#182 / AGENTS_FRONTEND_SHELL_NAV.md).

SwapPage never reads useSearchParams

SwapPage.tsx initializes:

const [fromToken, setFromToken] = useState<string>('')
const [toToken, setToToken] = useState<string>('')
const [inputAmount, setInputAmount] = useState('')

After factory pairs load, an effect overwrites empty / retail-hidden legs with defaultRetailSwapTokenPair (first two non-gem ids — P562-4). There is no search-param branch, so a deep link cannot win over that default.

The gap is already documented as known:

  • ust1SecondaryMarket.ts: “SwapPage does not yet honor token query params — keep the home Swap route only.” ust1SecondarySwapPath() returns '/'.
  • #678 explicitly deferred this: “Swap still must not honor arbitrary token query params unless that work is in-scope here and tested.”

Token pickers are factory-gated

TokenSearchSelect / tokenSearchQuery.ts (#481): options must stay inside getAllTokens(pairs) (factory legs + wrap natives from NATIVE_WRAPPED_PAIRS). Production hides gems (#562 P562, never ?showGems=1). Visible native tickers are LUNC / USTC, ids stay uluna / uusd (#630).

Create Pair must not grow query prefill (/create?a=&b= is C542-11 / P402-5). Trade already deep-links via /trade/:pairAddr. This issue is Swap only.

Contrast: other pages already parse query safely

Surface What it reads Pattern to copy
Charts ?price= parseChartsPriceQuery Hostile / overlong / javascript: → ignore; last repeated key wins; never echo raw HTML
/ust1 acquire parseUst1AcquirePrefill direction + human amount; isPositiveDecimalAmount; max 24 chars; clamp; never auto-submit
Create / Migrate useSearchParams is voided Intentional: no payee/admin/token prefill (M626-2)

Swap has zero of that for pay/receive.

Why the new implementation is needed

  1. Broken share / aggregator links. Uniswap and PancakeSwap use inputCurrency / outputCurrency. Terra Classic DEXes (Terraswap, Astroport, Terraport, Loop-style UIs) use from / to with uluna / uusd / terra1…. Token pages, listing forms, and partner CTAs already mint those URLs. CL8Y currently ignores them.
  2. /swap is the URL people type. Docs, legal allowlist, and E2E treat /swap as the Swap page. The catch-all redirect without search makes dex.cl8y.com/swap/?outputCurrency=… indistinguishable from a cold homepage visit.
  3. #678 / #508 leftover. /ust1 secondary-market Swap CTAs cannot deep-link a UST1 pair until Swap honors tokens. Acquire Guide already prefills /ust1; Swap cannot do the inverse.
  4. Retail expectation. Selecting tokens by URL is how DEXes work. Forcing a second picker after a shared link looks like the dApp is broken, not “defaults.”

This is frontend routing + a pure parser. No contract, indexer, or Coolify env change. Nginx SPA fallback already serves /swap.

Constraints / guardrails

  • Factory gate (#481). A resolved id applies only if it is in getAllTokens(pairs) (or the wrap native/CW20 pair already injected there). Unknown / unlisted CW20s must not be injected into the picker or quoted.
  • Checksum (#382). terra1… values must pass isValidTerraBech32Address. Format-only terra1 / truncated / bad checksum → ignore that side (keep default). Never render the raw hostile string into the trigger or a banner.
  • Gems (#562). Production must not apply gem addresses or gem symbols (RUBY, EMBER, … / COLUMBUS5_GEM_ADDRESSES). LocalTerra / VITE_SHOW_TEST_TOKENS=true may apply gems that are actually in the factory set. Never honor ?showGems=1 (X8).
  • Native tickers (#630). LUNC / lunc / uluna → id uluna. USTC / UST / uusd → id uusd. Display still LUNC/USTC. Do not treat a spoofed indexer symbol=UST1 as identity when the address is in the gem set.
  • Wrong chain → ignore, do not map. ETH, BNB, WETH, 0x… hex, ibc/…, factory/… → ignore that side. Do not map ETH→LUNC.
  • Same token both sides. Ignore the second side (or both if identical after resolve); keep the other default. Pay and receive must differ.
  • One-sided links. Uniswap-style ?outputCurrency= only: set receive, default pay to defaultRetailSwapTokenPair’s first id that is not the receive token (typically native/economic). Same for pay-only.
  • Amount is optional and pay-sided. Honor a conservative human exactAmount / amount / value / amountIn only when isPositiveDecimalAmount and length ≤ a small cap (reuse the 24-char idea from /ust1). No exactField=output / reverse quote in this issue (Swap is You Pay–driven). Never auto-submit. Disconnect stays quote-only (#678 A678).
  • Do not honor from the URL: slippage, expertMode, recipient / toAddress, pool_only / hybrid_optimize (hybrid stays always-on #596), chain= (except ignore), affiliate/referrer, javascript:, data:, http(s):, //.
  • Do not auto-submit, auto-connect, or skip Expert Mode / 5% confirm-again / 30% block / 99% extreme / blacklist / pause / code-id freeze / wrap pause.
  • Apply once after allTokens is non-empty. Query must win over defaultRetailSwapTokenPair without a flicker-then-overwrite race, and must not re-apply on every picker change (user can still flip tokens after landing).
  • #489. No lecture banner “we ignored your link.” Silent fail-closed to defaults is enough. Optional one-line only if a listed token is frozen/paused after apply (existing banners).
  • XSS. Parser returns allowlisted ids (uluna / uusd / checksummed terra1). Symbols used for resolve only. Logos still go through resolveTrustedTokenLogoUrl. No dangerouslySetInnerHTML. No window.location built from query values except React Router search preservation of the already-parsed URLSearchParams object (do not concatenate user strings into to=).
  • Do not add Create Pair / Manage / Migrate query prefill. Do not change Trade to query tokens (keep /trade/:pairAddr).
  • U1. A Swap deep link to UST1 is an AMM pair, not mint/redeem. Do not change /ust1 copy.
  • Outlet remount. Layout uses key={location.pathname} (#138). Search-only changes on / must not require a remount; apply via useSearchParams on the already-mounted SwapPage.

Relevant files

Path Why
frontend-dapp/src/App.tsx Add /swap (and /swap/) before *; preserve search + hash on redirect to /
frontend-dapp/src/pages/SwapPage.tsx Read parsed params; apply after tokens load; skip default overwrite when a valid query pair applied
frontend-dapp/src/utils/swapQueryParams.ts (new) Pure parse + resolve + factory-gate helper (Vitest-first)
frontend-dapp/src/utils/pairCatalogRank.ts defaultRetailSwapTokenPair, isRetailHiddenTestToken, gem lists
frontend-dapp/src/utils/tokenRegistry.ts Symbol → id for LUNC/USTC/CL8Y/UST1/cLUNC/cUSTC/vFDUSD/USTR
frontend-dapp/src/utils/terraAddressValidation.ts Bech32 checksum
frontend-dapp/src/utils/tokenSearchQuery.ts Factory-gated picker; do not bypass
frontend-dapp/src/utils/decimalAmountInput.ts Amount allowlist
frontend-dapp/src/utils/chartsPairRoute.ts Hostile-query pattern to copy
frontend-dapp/src/utils/ust1AcquirePrefill.ts Amount prefill pattern
frontend-dapp/src/utils/ust1SecondaryMarket.ts ust1SecondarySwapPath() can gain ?from=&to= after parser exists
frontend-dapp/src/components/common/navItems.ts Swap tab stays /
frontend-dapp/src/utils/legalClickwrap.ts /swap already allowlisted; redirect must stay same-origin
docker/frontend/nginx.conf Confirm SPA try_files (no new rewrite required)
docs/frontend.md Document supported keys + fail-closed rules
skills/AGENTS_FRONTEND_SHELL_NAV.md Today says catch-all redirects unknown paths to / — update that /swap preserves search
frontend-dapp/src/pages/SwapPage.test.tsx RTL: MemoryRouter initialEntries with query
frontend-dapp/e2e/swap-token-select-viewport.spec.ts Redirect still lands on Swap UI
  1. Route alias. Small wrapper (not a bare to="/"):

    path="/swap" → <Navigate to={{ pathname: '/', search: location.search, hash: location.hash }} replace />

    Same for trailing slash if the router does not collapse it. Keep catch-all * for everything else. Do not mount a second SwapPage at /swap (Swap tab end: true would go inactive).

  2. Pure helper parseSwapQueryParams(search: URLSearchParams): { payId, receiveId, payAmountHuman } with documented alias priority (first family with a non-empty value wins; within a family, last repeated key wins — same as Charts ?price=):

    Role Keys (case-insensitive names; values trimmed)
    Pay inputCurrency, from, tokenIn, token_in, sellToken, currencyIn, inToken, pay, offer
    Receive outputCurrency, to, tokenOut, token_out, buyToken, currencyOut, outToken, receive, ask
    Pay amount (optional) exactAmount, amount, value, amountIn

    Resolve a value in order: exact native denom (uluna/uusd) → registry / wrap product ticker (case-insensitive, capped length like Charts 80) → checksummed terra1. Else null.

  3. Apply in SwapPage once allTokens.length >= 2: resolve both sides against the factory set + retail gem filter; if pay invalid, keep default pay; if receive invalid, keep default receive; if both invalid, current defaults. Then set inputAmount only when the amount parse succeeds. Flip-direction and picker onChange still work afterward.

  4. Optional follow-through (same MR): ust1SecondarySwapPath() → '/?from=<ust1>&to=<quote>' (or /swap/?… which redirects) using published mainnet/env addresses, only when those ids are in the factory set. Update the “no fake query params” unit test.

  5. Canonical outbound links (token pages, Guide, listings) may mint /?from=&to= or /swap/?inputCurrency=&outputCurrency=. Prefer from/to with uluna/uusd/bech32 in first-party UI so we do not put display tickers in execute ids. Accepting Uniswap names is for inbound compatibility.

Acceptance criteria

  • AC1. /?from=uluna&to=<listed cw20> selects Pay LUNC and Receive that token after pairs load (columbus-5 or LocalTerra listed id).
  • AC2. /?inputCurrency=uluna&outputCurrency=<listed cw20> and Pancake-style ?outputCurrency= only (pay defaults) work the same.
  • AC3. https://dex.cl8y.com/swap/?from=…&to=… and /swap/?… (trailing slash OK) redirect or resolve to Swap with the same tokens (search preserved). Swap nav tab stays active.
  • AC4. LUNC / USTC / CL8Y / UST1 / cLUNC / cUSTC tickers resolve to the correct ids when those assets are in the factory/wrap universe.
  • AC5. Invalid bech32, javascript:alert(1), HTML, 0x…, ETH, overlong, empty, and gem (production) values are ignored per side; defaults remain; no raw string in the DOM trigger.
  • AC6. Optional legal human amount prefills You Pay; does not submit; does not skip #678 quote-only / insufficient / 5–30–99 gates.
  • AC7. After apply, the user can still change tokens and amount; flipping Pay/Receive works; query is not re-forced on those clicks.
  • AC8. Production cannot force a gem via query. ?showGems=1 is ignored.
  • AC9. Create Pair still has no ?a=&b= prefill. Trade URLs unchanged.
  • AC10. Existing /swap E2E without query still pass (redirect to / OK).
  • AC11. Docs (docs/frontend.md) list supported keys and fail-closed rules. Shell-nav skill notes /swap preserves search.

Test plan (functional paths)

# Path Expect
T1 / no query Unchanged defaultRetailSwapTokenPair
T2 /?from=uluna&to=<listed> Pay LUNC, receive token; quote still requires amount
T3 /?inputCurrency=uusd&outputCurrency=<listed> Pay USTC, receive token
T4 /?outputCurrency=<listed> only Receive set; pay is default other economic id
T5 /swap?from=uluna&to=<listed> and /swap/?from=… Lands on Swap with same pair (redirect OK)
T6 Ticker from=LUNC&to=UST1 (when UST1 listed) Ids uluna + UST1 CW20; UI shows LUNC / UST1
T7 Same id both sides At least one side stays default; pickers differ
T8 exactAmount=1.5 with valid tokens You Pay shows 1.5; no wallet popup
T9 User changes receive after apply Stays on new token (no query re-apply loop)
T10 LocalTerra gem in query when VITE_NETWORK=local May apply if factory-listed; production mainnet build must not
T11 Wrap pair from=uluna&to=<cLUNC> Direct wrap UI still valid
T12 ust1SecondarySwapPath() (if updated) Points at a real Swap URL that AC1/AC3 would honor
T13 page.goto('/swap') existing viewport/CLS E2E Still Swap UI
T14 Hash #/ junk Ignored; tokens from search only

Vitest: table-driven swapQueryParams.test.ts (aliases, hostile, gems, tickers, amount). RTL SwapPage with MemoryRouter initialEntries={['/?from=uluna&to=…']} and mocked getAllTokens. Playwright smoke: goto('/swap?from=uluna&to=…') assert pay/receive combobox labels (5 workers, PLAYWRIGHT_SKIP_CHAIN=1 OK if factory tokens exist).

Test plan (attack, hack, and abuse)

# Vector Expect
A1 ?from=javascript:alert(1) / data:text/html / https://evil / //evil Ignore; no navigation; no script
A2 ?to=<img onerror=…> / to=%3Cscript%3E Ignore; text nodes only
A3 Bech32 with bad checksum / terra1 / truncated Ignore that side
A4 Homoglyph / mixed-case bech32 CosmJS checksum fail → ignore; valid mixed-case that checksums may normalize
A5 Production gem CW20 or from=RUBY Ignore; no gem in picker; no gem-bridge quote
A6 ?showGems=1 / ?expertMode=1 / ?slippage=50 No effect on gem hide, Expert Mode, or Settings slippage
A7 ?recipient=<attacker> / toAddress= Unused; execute sender stays connected wallet
A8 ?pool_only=1 / hybrid_optimize=0 Hybrid always-on unchanged (#596)
A9 exactAmount=1e18 / negative / 999… (over cap) / commas Amount ignored; tokens may still apply
A10 Amount prefills a huge size #678 quote-only / insufficient / 5% confirm / 30% Expert still apply; no auto-sign
A11 Unlisted but valid terra1 CW20 Ignore (factory gate); do not LCD-fetch it into the list
A12 Open redirect via to=https://phish as receive or as Navigate to Parser never returns a URL; redirect target is always { pathname: '/', search, hash }
A13 Repeat keys from=uluna&from=uusd Last non-empty wins (document); no crash
A14 Query longer than search limits (kb of to=terra1…) Truncate / ignore overlong values before bech32 work
A15 Frozen / paused / blacklisted listed token Apply if factory-listed; existing freeze/pause/blacklist banners and submit blocks still fire — query is not a bypass
A16 Clickwrap / legal redirect_uri Still allowlist-only; /swap?… must not widen origins
A17 Clickjack / share of a lookalike token Pre-sign summary (#409) still shows resolved symbols + amounts before wallet

Verification criteria

  • make lint-frontend and Vitest for the new helper + SwapPage RTL + ust1SecondaryMarket path test (if updated).
  • Playwright: /swap?from=…&to=… (and / equivalent) shows the intended combobox labels; /swap without query still matches existing #498/#632 tests.
  • Production mental check: VITE_NETWORK=mainnet unit tests prove gem query does not apply.
  • Manual: open http://127.0.0.1:5173/swap?from=uluna&to=<local listed cw20> after make dev — Pay LUNC, receive that token, type an amount, confirm quote; change token; confirm query does not snap back. Repeat with Pancake outputCurrency only.
  • Coolify: https://dex.cl8y.com/swap/?inputCurrency=uluna&outputCurrency=<columbus-5 listed> selects the pair; Swap tab active; no extra /swap chrome.
  • make verify-issue-678 still passes (acquire banners). Do not regress #481 / #562 / #630 / #182 / #542.

Out of scope: reverse (exactField=output) quotes; Trade query tokens; Create Pair prefill; persisting query into localStorage; rewriting the URL on every picker change (nice-to-have later, not required); mapping EVM addresses.

## Summary The dApp homepage (`/` / Swap) ignores common DEX share and aggregator query strings (`?outputCurrency=`, `inputCurrency`, `from`, `to`, and the usual Uniswap / PancakeSwap / Terra Classic DEX aliases). `https://dex.cl8y.com/swap/?…` is supposed to work as well (redirect to `/` is OK **if the query string is preserved**). Today those links land on the default retail pair and look like the tokens were never requested. This is one product surface: **Swap deep-link query params** (route alias + parser + apply). Bundle `/swap` preservation and token/amount apply together. Do not split “redirect” vs “parse” into two issues. ## Current codebase ### Swap lives at `/`; `/swap` is not a real route [`frontend-dapp/src/App.tsx`](frontend-dapp/src/App.tsx) mounts `SwapPage` only at `/`. There is **no** `/swap` `Route`. Unknown paths hit: ```tsx <Route path="*" element={<Navigate to="/" replace />} /> ``` React Router `Navigate to="/"` **drops** `location.search` and `location.hash`. Visiting `https://dex.cl8y.com/swap/?from=uluna&to=<cw20>` therefore: 1. Nginx SPA fallback still serves `index.html` ([`docker/frontend/nginx.conf`](docker/frontend/nginx.conf) `try_files … /index.html`) — Coolify does not 404 the path. 2. The client then replaces the URL with `/` and **throws away** the query. 3. `SwapPage` never sees the intended tokens. Playwright already `page.goto('/swap')` ([`e2e/swap-token-select-viewport.spec.ts`](frontend-dapp/e2e/swap-token-select-viewport.spec.ts), [`e2e/swap-token-select-cls.spec.ts`](frontend-dapp/e2e/swap-token-select-cls.spec.ts)) and comments that `/swap` redirects to `/`. That is why E2E still “works” without query params. Legal clickwrap allowlists `https://dex.cl8y.com/swap` ([`legalClickwrap.test.ts`](frontend-dapp/src/utils/__tests__/legalClickwrap.test.ts)) as a same-origin path, not as a Swap parser. Shell Swap tab is `{ path: '/', label: 'Swap', end: true }` ([`navItems.ts`](frontend-dapp/src/components/common/navItems.ts)). Remaining on `/` after a **search-preserving** redirect keeps the Swap tab active ([#182](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/182) / [`AGENTS_FRONTEND_SHELL_NAV.md`](skills/AGENTS_FRONTEND_SHELL_NAV.md)). ### SwapPage never reads `useSearchParams` [`SwapPage.tsx`](frontend-dapp/src/pages/SwapPage.tsx) initializes: ```ts const [fromToken, setFromToken] = useState<string>('') const [toToken, setToToken] = useState<string>('') const [inputAmount, setInputAmount] = useState('') ``` After factory pairs load, an effect overwrites empty / retail-hidden legs with [`defaultRetailSwapTokenPair`](frontend-dapp/src/utils/pairCatalogRank.ts) (first two non-gem ids — **P562-4**). There is no search-param branch, so a deep link cannot win over that default. The gap is already documented as known: - [`ust1SecondaryMarket.ts`](frontend-dapp/src/utils/ust1SecondaryMarket.ts): “SwapPage does not yet honor token query params — keep the home Swap route only.” `ust1SecondarySwapPath()` returns `'/'`. - [#678](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/678) explicitly deferred this: “Swap still must not honor arbitrary token query params unless that work is in-scope here and tested.” ### Token pickers are factory-gated [`TokenSearchSelect`](frontend-dapp/src/components/trade/TokenSearchSelect.tsx) / [`tokenSearchQuery.ts`](frontend-dapp/src/utils/tokenSearchQuery.ts) (**#481**): options must stay inside `getAllTokens(pairs)` (factory legs + wrap natives from [`NATIVE_WRAPPED_PAIRS`](frontend-dapp/src/services/terraclassic/router.ts)). Production hides gems ([#562](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/562) **P562**, never `?showGems=1`). Visible native tickers are **LUNC** / **USTC**, ids stay `uluna` / `uusd` ([#630](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/630)). Create Pair **must not** grow query prefill (`/create?a=&b=` is **C542-11** / **P402-5**). Trade already deep-links via `/trade/:pairAddr`. This issue is **Swap only**. ### Contrast: other pages already parse query safely | Surface | What it reads | Pattern to copy | |---------|---------------|-----------------| | Charts `?price=` | [`parseChartsPriceQuery`](frontend-dapp/src/utils/chartsPairRoute.ts) | Hostile / overlong / `javascript:` → ignore; last repeated key wins; never echo raw HTML | | `/ust1` acquire | [`parseUst1AcquirePrefill`](frontend-dapp/src/utils/ust1AcquirePrefill.ts) | `direction` + human `amount`; `isPositiveDecimalAmount`; max 24 chars; clamp; **never auto-submit** | | Create / Migrate | `useSearchParams` is **voided** | Intentional: no payee/admin/token prefill (**M626-2**) | Swap has **zero** of that for pay/receive. ## Why the new implementation is needed 1. **Broken share / aggregator links.** Uniswap and PancakeSwap use `inputCurrency` / `outputCurrency`. Terra Classic DEXes (Terraswap, Astroport, Terraport, Loop-style UIs) use `from` / `to` with `uluna` / `uusd` / `terra1…`. Token pages, listing forms, and partner CTAs already mint those URLs. CL8Y currently ignores them. 2. **`/swap` is the URL people type.** Docs, legal allowlist, and E2E treat `/swap` as the Swap page. The catch-all redirect without `search` makes `dex.cl8y.com/swap/?outputCurrency=…` indistinguishable from a cold homepage visit. 3. **#678 / #508 leftover.** `/ust1` secondary-market Swap CTAs cannot deep-link a UST1 pair until Swap honors tokens. Acquire Guide already prefills `/ust1`; Swap cannot do the inverse. 4. **Retail expectation.** Selecting tokens by URL is how DEXes work. Forcing a second picker after a shared link looks like the dApp is broken, not “defaults.” This is **frontend routing + a pure parser**. No contract, indexer, or Coolify env change. Nginx SPA fallback already serves `/swap`. ## Constraints / guardrails - **Factory gate (#481).** A resolved id applies only if it is in `getAllTokens(pairs)` (or the wrap native/CW20 pair already injected there). Unknown / unlisted CW20s must **not** be injected into the picker or quoted. - **Checksum (#382).** `terra1…` values must pass [`isValidTerraBech32Address`](frontend-dapp/src/utils/terraAddressValidation.ts). Format-only `terra1` / truncated / bad checksum → ignore that side (keep default). Never render the raw hostile string into the trigger or a banner. - **Gems (#562).** Production must **not** apply gem addresses or gem symbols (`RUBY`, `EMBER`, … / `COLUMBUS5_GEM_ADDRESSES`). LocalTerra / `VITE_SHOW_TEST_TOKENS=true` may apply gems that are actually in the factory set. Never honor `?showGems=1` (X8). - **Native tickers (#630).** `LUNC` / `lunc` / `uluna` → id `uluna`. `USTC` / `UST` / `uusd` → id `uusd`. Display still LUNC/USTC. Do not treat a spoofed indexer `symbol=UST1` as identity when the address is in the gem set. - **Wrong chain → ignore, do not map.** `ETH`, `BNB`, `WETH`, `0x…` hex, `ibc/…`, `factory/…` → ignore that side. Do **not** map ETH→LUNC. - **Same token both sides.** Ignore the second side (or both if identical after resolve); keep the other default. Pay and receive must differ. - **One-sided links.** Uniswap-style `?outputCurrency=` only: set receive, default pay to `defaultRetailSwapTokenPair`’s first id that is not the receive token (typically native/economic). Same for pay-only. - **Amount is optional and pay-sided.** Honor a conservative human `exactAmount` / `amount` / `value` / `amountIn` only when `isPositiveDecimalAmount` and length ≤ a small cap (reuse the 24-char idea from `/ust1`). **No** `exactField=output` / reverse quote in this issue (Swap is You Pay–driven). Never auto-submit. Disconnect stays quote-only ([#678](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/678) A678). - **Do not honor from the URL:** `slippage`, `expertMode`, `recipient` / `toAddress`, `pool_only` / `hybrid_optimize` (hybrid stays always-on [#596](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/596)), `chain=` (except ignore), affiliate/referrer, `javascript:`, `data:`, `http(s):`, `//`. - **Do not auto-submit, auto-connect, or skip** Expert Mode / 5% confirm-again / 30% block / 99% extreme / blacklist / pause / code-id freeze / wrap pause. - **Apply once** after `allTokens` is non-empty. Query must win over `defaultRetailSwapTokenPair` without a flicker-then-overwrite race, and must **not** re-apply on every picker change (user can still flip tokens after landing). - **#489.** No lecture banner “we ignored your link.” Silent fail-closed to defaults is enough. Optional one-line only if a listed token is frozen/paused **after** apply (existing banners). - **XSS.** Parser returns allowlisted ids (`uluna` / `uusd` / checksummed `terra1`). Symbols used for resolve only. Logos still go through `resolveTrustedTokenLogoUrl`. No `dangerouslySetInnerHTML`. No `window.location` built from query values except React Router search preservation of the **already-parsed** `URLSearchParams` object (do not concatenate user strings into `to=`). - **Do not** add Create Pair / Manage / Migrate query prefill. Do not change Trade to query tokens (keep `/trade/:pairAddr`). - **U1.** A Swap deep link to UST1 is an AMM pair, not mint/redeem. Do not change `/ust1` copy. - **Outlet remount.** `Layout` uses `key={location.pathname}` ([#138](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/138)). Search-only changes on `/` must **not** require a remount; apply via `useSearchParams` on the already-mounted `SwapPage`. ## Relevant files | Path | Why | |------|-----| | `frontend-dapp/src/App.tsx` | Add `/swap` (and `/swap/`) **before** `*`; preserve `search` + `hash` on redirect to `/` | | `frontend-dapp/src/pages/SwapPage.tsx` | Read parsed params; apply after tokens load; skip default overwrite when a valid query pair applied | | `frontend-dapp/src/utils/swapQueryParams.ts` (**new**) | Pure parse + resolve + factory-gate helper (Vitest-first) | | `frontend-dapp/src/utils/pairCatalogRank.ts` | `defaultRetailSwapTokenPair`, `isRetailHiddenTestToken`, gem lists | | `frontend-dapp/src/utils/tokenRegistry.ts` | Symbol → id for LUNC/USTC/CL8Y/UST1/cLUNC/cUSTC/vFDUSD/USTR | | `frontend-dapp/src/utils/terraAddressValidation.ts` | Bech32 checksum | | `frontend-dapp/src/utils/tokenSearchQuery.ts` | Factory-gated picker; do not bypass | | `frontend-dapp/src/utils/decimalAmountInput.ts` | Amount allowlist | | `frontend-dapp/src/utils/chartsPairRoute.ts` | Hostile-query pattern to copy | | `frontend-dapp/src/utils/ust1AcquirePrefill.ts` | Amount prefill pattern | | `frontend-dapp/src/utils/ust1SecondaryMarket.ts` | `ust1SecondarySwapPath()` can gain `?from=&to=` **after** parser exists | | `frontend-dapp/src/components/common/navItems.ts` | Swap tab stays `/` | | `frontend-dapp/src/utils/legalClickwrap.ts` | `/swap` already allowlisted; redirect must stay same-origin | | `docker/frontend/nginx.conf` | Confirm SPA `try_files` (no new rewrite required) | | `docs/frontend.md` | Document supported keys + fail-closed rules | | `skills/AGENTS_FRONTEND_SHELL_NAV.md` | Today says catch-all redirects unknown paths to `/` — update that `/swap` preserves search | | `frontend-dapp/src/pages/SwapPage.test.tsx` | RTL: MemoryRouter `initialEntries` with query | | `frontend-dapp/e2e/swap-token-select-viewport.spec.ts` | Redirect still lands on Swap UI | ## Recommended direction 1. **Route alias.** Small wrapper (not a bare `to="/"`): `path="/swap"` → `<Navigate to={{ pathname: '/', search: location.search, hash: location.hash }} replace />` Same for trailing slash if the router does not collapse it. Keep catch-all `*` for everything else. Do **not** mount a second `SwapPage` at `/swap` (Swap tab `end: true` would go inactive). 2. **Pure helper** `parseSwapQueryParams(search: URLSearchParams): { payId, receiveId, payAmountHuman }` with documented alias priority (first family with a non-empty value wins; within a family, last repeated key wins — same as Charts `?price=`): | Role | Keys (case-insensitive names; values trimmed) | |------|--------------------------------------------------| | Pay | `inputCurrency`, `from`, `tokenIn`, `token_in`, `sellToken`, `currencyIn`, `inToken`, `pay`, `offer` | | Receive | `outputCurrency`, `to`, `tokenOut`, `token_out`, `buyToken`, `currencyOut`, `outToken`, `receive`, `ask` | | Pay amount (optional) | `exactAmount`, `amount`, `value`, `amountIn` | Resolve a value in order: exact native denom (`uluna`/`uusd`) → registry / wrap product ticker (case-insensitive, capped length like Charts 80) → checksummed `terra1`. Else `null`. 3. **Apply in SwapPage** once `allTokens.length >= 2`: resolve both sides against the factory set + retail gem filter; if pay invalid, keep default pay; if receive invalid, keep default receive; if both invalid, current defaults. Then set `inputAmount` only when the amount parse succeeds. Flip-direction and picker `onChange` still work afterward. 4. **Optional follow-through (same MR):** `ust1SecondarySwapPath()` → `'/?from=<ust1>&to=<quote>'` (or `/swap/?…` which redirects) using published mainnet/env addresses, **only** when those ids are in the factory set. Update the “no fake query params” unit test. 5. **Canonical outbound links** (token pages, Guide, listings) may mint `/?from=&to=` or `/swap/?inputCurrency=&outputCurrency=`. Prefer `from`/`to` with `uluna`/`uusd`/bech32 in first-party UI so we do not put display tickers in execute ids. Accepting Uniswap names is for **inbound** compatibility. ## Acceptance criteria - [ ] **AC1.** `/?from=uluna&to=<listed cw20>` selects Pay **LUNC** and Receive that token after pairs load (columbus-5 or LocalTerra listed id). - [ ] **AC2.** `/?inputCurrency=uluna&outputCurrency=<listed cw20>` and Pancake-style `?outputCurrency=` only (pay defaults) work the same. - [ ] **AC3.** `https://dex.cl8y.com/swap/?from=…&to=…` and `/swap/?…` (trailing slash OK) redirect or resolve to Swap with **the same tokens** (search preserved). Swap nav tab stays active. - [ ] **AC4.** `LUNC` / `USTC` / `CL8Y` / `UST1` / `cLUNC` / `cUSTC` tickers resolve to the correct ids when those assets are in the factory/wrap universe. - [ ] **AC5.** Invalid bech32, `javascript:alert(1)`, HTML, `0x…`, `ETH`, overlong, empty, and gem (production) values are ignored per side; defaults remain; **no** raw string in the DOM trigger. - [ ] **AC6.** Optional legal human amount prefills You Pay; does not submit; does not skip #678 quote-only / insufficient / 5–30–99 gates. - [ ] **AC7.** After apply, the user can still change tokens and amount; flipping Pay/Receive works; query is not re-forced on those clicks. - [ ] **AC8.** Production cannot force a gem via query. `?showGems=1` is ignored. - [ ] **AC9.** Create Pair still has **no** `?a=&b=` prefill. Trade URLs unchanged. - [ ] **AC10.** Existing `/swap` E2E without query still pass (redirect to `/` OK). - [ ] **AC11.** Docs (`docs/frontend.md`) list supported keys and fail-closed rules. Shell-nav skill notes `/swap` preserves search. ## Test plan (functional paths) | # | Path | Expect | |---|------|--------| | T1 | `/` no query | Unchanged `defaultRetailSwapTokenPair` | | T2 | `/?from=uluna&to=<listed>` | Pay LUNC, receive token; quote still requires amount | | T3 | `/?inputCurrency=uusd&outputCurrency=<listed>` | Pay USTC, receive token | | T4 | `/?outputCurrency=<listed>` only | Receive set; pay is default other economic id | | T5 | `/swap?from=uluna&to=<listed>` and `/swap/?from=…` | Lands on Swap with same pair (redirect OK) | | T6 | Ticker `from=LUNC&to=UST1` (when UST1 listed) | Ids `uluna` + UST1 CW20; UI shows LUNC / UST1 | | T7 | Same id both sides | At least one side stays default; pickers differ | | T8 | `exactAmount=1.5` with valid tokens | You Pay shows `1.5`; no wallet popup | | T9 | User changes receive after apply | Stays on new token (no query re-apply loop) | | T10 | LocalTerra gem in query when `VITE_NETWORK=local` | May apply if factory-listed; production mainnet build must not | | T11 | Wrap pair `from=uluna&to=<cLUNC>` | Direct wrap UI still valid | | T12 | `ust1SecondarySwapPath()` (if updated) | Points at a real Swap URL that AC1/AC3 would honor | | T13 | `page.goto('/swap')` existing viewport/CLS E2E | Still Swap UI | | T14 | Hash `#/` junk | Ignored; tokens from search only | Vitest: table-driven `swapQueryParams.test.ts` (aliases, hostile, gems, tickers, amount). RTL `SwapPage` with `MemoryRouter initialEntries={['/?from=uluna&to=…']}` and mocked `getAllTokens`. Playwright smoke: `goto('/swap?from=uluna&to=…')` assert pay/receive combobox labels (5 workers, `PLAYWRIGHT_SKIP_CHAIN=1` OK if factory tokens exist). ## Test plan (attack, hack, and abuse) | # | Vector | Expect | |---|--------|--------| | A1 | `?from=javascript:alert(1)` / `data:text/html` / `https://evil` / `//evil` | Ignore; no navigation; no script | | A2 | `?to=<img onerror=…>` / `to=%3Cscript%3E` | Ignore; text nodes only | | A3 | Bech32 with bad checksum / `terra1` / truncated | Ignore that side | | A4 | Homoglyph / mixed-case bech32 | CosmJS checksum fail → ignore; valid mixed-case that checksums may normalize | | A5 | Production gem CW20 or `from=RUBY` | Ignore; no gem in picker; no gem-bridge quote | | A6 | `?showGems=1` / `?expertMode=1` / `?slippage=50` | No effect on gem hide, Expert Mode, or Settings slippage | | A7 | `?recipient=<attacker>` / `toAddress=` | Unused; execute `sender` stays connected wallet | | A8 | `?pool_only=1` / `hybrid_optimize=0` | Hybrid always-on unchanged (#596) | | A9 | `exactAmount=1e18` / negative / `999…` (over cap) / commas | Amount ignored; tokens may still apply | | A10 | Amount prefills a huge size | #678 quote-only / insufficient / 5% confirm / 30% Expert still apply; **no** auto-sign | | A11 | Unlisted but valid `terra1` CW20 | Ignore (factory gate); do not LCD-fetch it into the list | | A12 | Open redirect via `to=https://phish` as receive **or** as Navigate `to` | Parser never returns a URL; redirect target is always `{ pathname: '/', search, hash }` | | A13 | Repeat keys `from=uluna&from=uusd` | Last non-empty wins (document); no crash | | A14 | Query longer than search limits (kb of `to=terra1…`) | Truncate / ignore overlong values before bech32 work | | A15 | Frozen / paused / blacklisted listed token | Apply if factory-listed; existing freeze/pause/blacklist banners and submit blocks still fire — query is not a bypass | | A16 | Clickwrap / legal `redirect_uri` | Still allowlist-only; `/swap?…` must not widen origins | | A17 | Clickjack / share of a lookalike token | Pre-sign summary (#409) still shows **resolved** symbols + amounts before wallet | ## Verification criteria - `make lint-frontend` and Vitest for the new helper + SwapPage RTL + `ust1SecondaryMarket` path test (if updated). - Playwright: `/swap?from=…&to=…` (and `/` equivalent) shows the intended combobox labels; `/swap` without query still matches existing #498/#632 tests. - Production mental check: `VITE_NETWORK=mainnet` unit tests prove gem query does not apply. - Manual: open `http://127.0.0.1:5173/swap?from=uluna&to=<local listed cw20>` after `make dev` — Pay LUNC, receive that token, type an amount, confirm quote; change token; confirm query does not snap back. Repeat with Pancake `outputCurrency` only. - Coolify: `https://dex.cl8y.com/swap/?inputCurrency=uluna&outputCurrency=<columbus-5 listed>` selects the pair; Swap tab active; no extra `/swap` chrome. - `make verify-issue-678` still passes (acquire banners). Do not regress #481 / #562 / #630 / #182 / #542. **Out of scope:** reverse (`exactField=output`) quotes; Trade query tokens; Create Pair prefill; persisting query into `localStorage`; rewriting the URL on every picker change (nice-to-have later, not required); mapping EVM addresses.
PlasticDigits commented 2026-08-31 04:56:53 +00:00 (Migrated from gitlab.com)

marked as related to #678

marked as related to #678
PlasticDigits commented 2026-08-31 04:56:53 +00:00 (Migrated from gitlab.com)

marked as related to #508

marked as related to #508
PlasticDigits commented 2026-08-31 04:56:54 +00:00 (Migrated from gitlab.com)

marked as related to #481

marked as related to #481
PlasticDigits commented 2026-08-31 04:56:55 +00:00 (Migrated from gitlab.com)

marked as related to #562

marked as related to #562
PlasticDigits commented 2026-08-31 05:18:16 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1199

mentioned in merge request !1199
PlasticDigits commented 2026-08-31 05:20:26 +00:00 (Migrated from gitlab.com)

Implementation is in !1199 (feat/711-swap-query-params).

make verify-issue-711 passed twice (parser + RTL + C542-11 + docs/skills). Playwright /swap smoke and Coolify were not run here (no LocalTerra in this session).

Follow-up for marketing-site DEX hrefs (gated on this MR merge): opened on PlasticDigits/CL8Y-web — deep-link /?from=uluna&to=<CL8Y CW20> on the Trade-on-DEX row; keep product origin https://dex.cl8y.com query-free.

Implementation is in !1199 (`feat/711-swap-query-params`). `make verify-issue-711` passed twice (parser + RTL + C542-11 + docs/skills). Playwright `/swap` smoke and Coolify were not run here (no LocalTerra in this session). Follow-up for marketing-site DEX hrefs (gated on this MR merge): opened on PlasticDigits/CL8Y-web — deep-link `/?from=uluna&to=<CL8Y CW20>` on the Trade-on-DEX row; keep product origin `https://dex.cl8y.com` query-free.
PlasticDigits commented 2026-08-31 05:36:04 +00:00 (Migrated from gitlab.com)

marked as related to #713

marked as related to #713
PlasticDigits commented 2026-08-31 05:36:04 +00:00 (Migrated from gitlab.com)

mentioned in issue #713

mentioned in issue #713
PlasticDigits commented 2026-08-31 05:36:19 +00:00 (Migrated from gitlab.com)

Follow-up for #711 leftovers (reverse quote, URL rewrite, Swap Share, Create/Trade query prefill) is bundled in #713. Keep that work off !1199.

Follow-up for #711 leftovers (reverse quote, URL rewrite, Swap Share, Create/Trade query prefill) is bundled in #713. Keep that work off !1199.
PlasticDigits commented 2026-08-31 09:54:26 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1200

mentioned in merge request !1200
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-31 11:14:16 +00:00
PlasticDigits commented 2026-08-31 11:16:21 +00:00 (Migrated from gitlab.com)

mentioned in commit 13d586b9f6

mentioned in commit 13d586b9f64be1bad8bc68154d3e319b657295de
PlasticDigits commented 2026-08-31 11:18:41 +00:00 (Migrated from gitlab.com)

mentioned in issue #714

mentioned in issue #714
PlasticDigits commented 2026-08-31 11:18:41 +00:00 (Migrated from gitlab.com)

marked as related to #714

marked as related to #714
PlasticDigits commented 2026-08-31 11:18:55 +00:00 (Migrated from gitlab.com)

Merged as !1199 into main (plus follow-up !1200 / #713).

Sanity check before merge:

  • Makefile conflict vs greedy 708–710 help text — resolved (kept 708–710 + verify-issue-711).
  • AC1 hole: apply-once could run against wrap natives before factory pairs loaded and drop a listed CW20. Fixed on the MR (pairsQuery.isSuccess gate + delayed-pairs RTL). make verify-issue-711 5/5 after that.
  • Playwright listed-CW20 / Coolify share links were not run here.

Post-merge leftover: #714. Do not reopen this issue unless a merged invariant is wrong.

Merged as !1199 into main (plus follow-up !1200 / #713). Sanity check before merge: - Makefile conflict vs greedy 708–710 help text — resolved (kept 708–710 + `verify-issue-711`). - **AC1 hole:** apply-once could run against wrap natives before factory pairs loaded and drop a listed CW20. Fixed on the MR (`pairsQuery.isSuccess` gate + delayed-pairs RTL). `make verify-issue-711` 5/5 after that. - Playwright listed-CW20 / Coolify share links were not run here. Post-merge leftover: #714. Do not reopen this issue unless a merged invariant is wrong.
PlasticDigits commented 2026-08-31 16:08:49 +00:00 (Migrated from gitlab.com)

mentioned in issue #715

mentioned in issue #715
PlasticDigits commented 2026-08-31 16:08:49 +00:00 (Migrated from gitlab.com)

marked as related to #715

marked as related to #715
PlasticDigits commented 2026-09-01 07:32:29 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1201

mentioned in merge request !1201
PlasticDigits commented 2026-09-01 07:48:09 +00:00 (Migrated from gitlab.com)

mentioned in issue #716

mentioned in issue #716
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#711
No description provided.