Files
labrinyang 4e317eff6d LP TERMINAL — terminal-style LP frontend for Robinhood Chain
Static SPA over UP33 (ve(3,3)) + the official Uniswap v2/v3 deployments:
browse and add liquidity across all three, manage CL/v2 positions, one-token
ZAP adds, aggregator vs native swaps, and LP-based limit orders. Ships with a
zero-dependency pool indexer for uniswap discovery.

Browser-wallet signing only; no key material anywhere.
2026-07-17 19:10:30 +08:00

24 KiB
Raw Permalink Blame History

LP TERMINAL

Terminal-style frontend for LPs on Robinhood Chain (chainId 4663): UP33 (ve(3,3) DEX) plus the official Uniswap v2 + v3 deployments. POSITIONS shows and manages UP33 + univ3, POOLS browses/adds liquidity across all three, distinguished by protocol badges (brand mark + colored label). Discovery runs on a self-hosted pool indexer (see below) with a client-side fallback. Terminal style — full-bleed layout, not a boxed console. Every contract address the app touches lives in src/config/addresses.ts, each one verified against Blockscout's verified source — check them yourself before you trust this with funds.

Status: v1, built for personal use and opened up as-is. See Known v1 limits. MIT licensed — see Disclaimer before you point it at real money.

Run

npm install
cp .env.example .env   # every key is optional; defaults hit public endpoints
npm run smoke          # optional: live-chain read-layer validation (TickMath, ABIs, quotes)
npm run indexer        # pool indexer on :8787 (first boot backfills ~10 min; optional but
                       # recommended — without it POOLS falls back to dexscreener discovery)
npm run dev            # http://localhost:5173 (proxies /api -> :8787)

Environment comes from the repo-root .env (via vite envDir) — see .env.example for the annotated template:

key use
RPC private Robinhood Chain RPC (secret — personal/local builds only; leave unset for public builds, see Chain reads)
KYBERSWAP_AGGREGATOR_API_BASE_URL kyber aggregator base
KYBERSWAP_CHAIN chain slug (robinhood)
KYBERSWAP_ROUTER_ADDRESS whitelist — swap calldata is only ever sent to this address
KYBERSWAP_FEE_BPS optional platform fee in bps on kyber swaps (e.g. 10 = 0.1%); off when unset
KYBERSWAP_FEE_RECEIVER optional; address that receives the fee (both must be set to activate)
VITE_WALLETCONNECT_PROJECT_ID optional; only needed for WalletConnect QR pairing (injected wallets work without it)

KyberSwap platform fees are verified working on this chain: the fee rides the routes request (feeAmount/chargeFeeBy=currency_out/isInBps/feeReceiver), is echoed in routeSummary.extraFee, deducted from the quoted output, and encoded into the swap calldata by route/build — the router pays the receiver on-chain. Price displays (UP price in the add-LP sims) always quote fee-free.

Signing is browser-wallet only (RainbowKit / injected EIP-6963). No private keys anywhere.

i18n (en/zh): react-i18next with typed keys — catalogs in src/i18n/en.ts (source of truth) + zh.ts (typeof en enforces identical key structure at compile time). Language: footer lang: switcher, persisted (up33.lang.v1), ?lang= view-only override (screenshots), <html lang> + RainbowKit modal locale follow. Non-React modules (tx step labels, zap planner) import the singleton t from src/i18n; revert hints resolve lazily so they follow the active language at error time. Number/date formats stay en-US in both locales (terminal convention; zh uses the same digit grouping). The #lab dev page stays English.

Tabs

Keyboard: 13 switch tabs, 4 opens LIMIT, / focuses the pool filter. Tabs are hash-routed (#pools, #swap, …) so reloads and deep links keep your place. #lab renders the component lab (synthetic data) for visual tweaking.

  • [1] POOLS — one table, three protocols, sorted by TVL by default. UP33 v2 + CL pools come from live factory enumeration; Uniswap v2 + v3 pools come from the pool indexer (/api — the FULL catalog built from factory events — six figures and growing ~20k pools/day, launchpads mint univ3 pools continuously — chain-derived TVL, GeckoTerminal 24h stats). Columns: fee rate, reserves/price (auto-oriented quote/base), TVL / VOL 24H / FEES 24H (UP33: DexScreener + Goldsky v2 subgraph; uniswap: indexer), FEE APR / EMIT APR, UP/wk emissions, vote share; all numeric headers sortable; ● MY POOLS filter marks pools you're in. The UNISWAP ⌕ row searches the whole catalog — token address / pool address / symbol / sym0/sym1 pair, empty = everything by TVL — with a HIDE <$1K dust chip (on by default: 95% of the catalog is dust meme pools); if the indexer is down it falls back to DexScreener discovery with on-chain factory.getPool verification (v3 top 30, amber notice). Inline add-liquidity everywhere: v2 auto-ratio (UP33 Solidly router or vanilla Router02, per pool); CL/univ3 with the full range picker — symmetric presets (±0.5/1/2/5/10/20/30%, FULL), custom ±%, one-sided ↑ABOVE / ↓BELOW (single-token deposits that start earning when price enters — sell-the-rise / buy-the-dip), price-bounds input (snapped to tick spacing), raw ticks — with a live range-bar preview; amounts rebalance automatically when the range changes. univ3 mints go through the official NPM (fee-keyed mint struct, no levy, no gauge — fee APR only).

    ZAP — one-token add (all four pool kinds + increase): every add panel has a FUND: PAIR | ⚡ ZAP switch. ZAP funds the position with ONE token (either side, or native ETH when a side is WETH — wrapped as step 1): it solves how much to swap so the two piles match the deposit ratio the target needs (CL band math / v2 reserves; seeded at spot, refined over ≤2 kyber quotes since the execution rate moves the answer), previews the plan (SPLIT / SWAP with min-out + impact + route, DEPOSIT with est. dust, PROJECTED APRs), lists the exact tx sequence (numbered, live states), then runs it step by step: wrap? → approve → gated kyber swap → approve both sides → mint / increase / addLiquidity. The deposit uses the amounts that actually arrived (receipt Transfer logs), never the quote; any failure halts — every intermediate asset is a normal wallet balance, nothing strands. REWARDS column shows its full emissions sub-line only under the UP33 filter (elsewhere it's a slim /APR column).

    APR semantics (ve(3,3): a position earns one or the other, never both):

    • FEE APRunstaked LP net fee yield: vol24h × feeRate × 365 / TVL, CL further × (1 unstaked levy). Staked LPs earn zero fees (theirs go to the pool's voters). CL number is the pool average — a concentrated in-range position earns proportionally more.
    • EMIT APRstaked LP UP yield: rewardRate × 31.536M × UP price / staked TVL, where staked TVL is v2 gauge.totalSupply/pool.totalSupply × TVL, CL stakedLiquidity/liquidity × TVL (active-liquidity proxy; out-of-range staked earns nothing). rewardRate is the live post-cap stream, so gauge-cap burns are already reflected; it is only committed until the Thursday flip (periodFinish), and later cap releases within an epoch can raise it (values refresh live). = emissions streaming to ~zero staked TVL (first staker takes it all).
    • Simple APR, no compounding; both dilute as TVL/staked TVL grows.
    • Add-LP simulation: both add panels show a PROJECTED line for your prospective position — deposit USD (priced via USDG/WETH/UP anchors), your share of active liquidity, and your fee/emit APR. For CL this is position-specific: share = L_yours / (activeLiquidity + L_yours) (fees) and / (stakedLiquidity + L_yours) (emissions), so range concentration and self-dilution are captured exactly — e.g. a ±2% range earns several× the pool-average APR while in range. Out-of-range ranges warn that they earn nothing.
  • [2] POSITIONS — summary strip (LP VALUE in USD across everything incl. uncollected fees, PENDING UP with USD value + live +UP/day accrual + CLAIM ALL, range status, open range-order count with filled alert), then every held LP sorted staked-first / biggest-first. Every card answers the two questions an LP manages by — worth (value ≈ $…, tokens priced off the pool's own price against USDG/WETH/UP anchors; fees/pending shown in USD too) and earning right now (earning line: staked → UP/day + $/day + APR + share of staked liq; unstaked/univ3 in range → fee APR + $/day + share of active liq from 24h stats — indexer stats fetched per held uniswap pool; out of range → red zero; emissions dry → amber). Multi-protocol: UP33 CL/v2 positions plus wallet positions from the official Uniswap v3 NPM (0x7399…E0D3, pairing chain-verified — Blockscout also lists several unofficial forks). Each card carries a protocol badge (UP arrow / Uniswap unicorn, brand-colored). Uniswap pools are discovered per position via factory.getPool, unknown pair tokens get erc20 metadata fetched live, fees use the same collect-simulation, and increase/decrease/collect/withdraw all work (write entrypoints are signature-identical; only the NPM address differs). No staking on univ3 (no gauges), and SWAP→LIMIT tags never attach to univ3 ids (tokenIds are only unique per NPM):

    • CL: range bar (ends = your price bounds, marker = current price, % drift to each bound, re-entry distance when out of range, in/near/out coloring), holdings, uncollected fees (exact, via collect simulation) or pending UP when staked. Actions: stake/unstake, claim UP, collect fees, increase, decrease (decrease+collect), withdraw (double-click confirm). The increase panel shows wallet balances + MAX, auto-links the two amounts at the live pool price, and previews the exact pull/new size — the range itself is immutable (increaseLiquidity has no tick params; it stacks liquidity on the band fixed at mint). Range orders placed via SWAP→LIMIT carry a LIMIT sell→buy badge, an order-mode range bar (waiting/filling x%/filled instead of the red out-of-range alarm, priced in the sell token), and a state-aware one-click action: CANCEL — GET <sell> BACK (unfilled) / CLOSE NOW (partial) / WITHDRAW → LOCK IN <buy> (filled); the tag clears on 100% withdraw.
    • after any claim (or CL unstake, which auto-claims) confirms, the log shows the exact UP received with a SWAP → ETH button — it jumps to SWAP prefilled with the claimed amount.
    • v2: wallet vs staked LP, underlying amounts, claimable pool fees, pending UP. Actions: stake all / unstake all / claim UP / claim fees / remove %.
  • [3] SWAP — two modes:

    • MARKET — KyberSwap aggregator quote vs UP33-native best (v2 getAmountsOut + CL quoter across all matching pools), side-by-side with bps diff; executes whichever you select. ETH⇄WETH wrap/unwrap built in.
    • LIMIT · SELL VIA LP (#limit, key 4) — sell a token with a one-sided CL range order. The point is maker-not-taker economics: a market swap through the pool pays its fee (1% on WETH/UP); a range order pays none and earns fees while filling (the panel shows the comparison and an est. $/day while in-band). Pick sell/buy tokens (auto-picks the deepest CL pool; chips if several fee tiers), 25/50/75/MAX amount chips with USD estimate, choose a band: TIGHT · 1 TICK (default — one tick-spacing hugging market, fills on the first uptick) or +1→3% … +10→25% / custom, snapped to tick spacing; a ? chip opens a structured band explainer (start/end/avg/grid rows). The panel renders as a structured order ticket (aligned key-value sections, no prose walls) phrased in the sell token's price: ORDER (fill-start / fully-sold / avg-fill premiums with exact prices — avg is the band's geometric mean, exact closed form — band ticks with a ≡ TIGHT marker when a small custom band snaps onto the tight one, order-mode range bar), PROJECTED · FULL FILL (avg price vs market, exact proceeds + USD, est. fee income /day while in band), `FEES · MAKER VS TAKER` (0% vs pool fee ≈ on your size), MECHANICS (fills / un-fills / after-fill withdraw / don't stake). Placing mints an out-of-range one-sided position (sell-side min ≈ 100% guards against the price having entered the band). Fills as the token appreciates through the band and earns pool fees while filling; un-fills if price retreats and nothing auto-executes — withdraw after fill to lock in.

Safety rails

  • Kyber calldata is opaque → four gates before sending: the API's routerAddress must equal the .env whitelist (and the tx to is always the whitelist address, never the API's), transactionValue must match expectation exactly (0 for ERC-20 in, amountIn for native ETH), the built amountIn must equal the request, and the built amountOut must be ≥ the fresh quote minus the user's slippage (catches degraded/tampered builds). The gates live in ONE place (lib/kyberExec.ts) shared by SWAP and ZAP so they can never diverge.
  • ZAP adds two more on its swap leg: the fresh route's tokenOut must be the pool's counter-token, and the fresh output must still be within slippage (+0.5% grace) of the previewed plan — otherwise it halts before the wallet ever sees the tx. Deposits then use the received-amount ground truth from the receipt, and approvals stay exact-amount per step.
  • Exact-amount approvals only (no infinite approvals).
  • All writes pinned to chainId 4663; wrong-network banner blocks confusion.
  • Everything reads live on-chain state each ~15s (protocol parameters are Safe-controlled and can change at any time). Pools hosting your range orders get a dedicated 4s slot0 feed (single multicall) so fill %, holdings and the range bar track near-live; numeric updates flash green ▲ / red ▼ by direction, and the range-bar marker glides so drift direction is visible.

Pool indexer (indexer/)

The heavier backend behind uniswap discovery. Zero npm dependencies beyond the app's own (viem, tsx; storage is node's built-in node:sqlite, requires node ≥ 22.13). One process, four loops, a read-only HTTP API:

  • Catalog — the authoritative pool list, built ONLY from the official factories: univ3 PoolCreated logs (backfill via Blockscout's uncapped etherscan-style getLogs, ~22 pages for full history, with automatic fallback to windowed RPC getLogs if Blockscout flakes; then a 10s RPC tail), univ2 allPairsLength/allPairs enumeration (backfill == tail). Third-party APIs never admit a pool — they only enrich — so spoofed/fork pools are structurally excluded (stronger than the old per-query factory.getPool round-trip, which remains as the client-side fallback).
  • State sweeps (multicall, 400 calls/aggregate) — univ3: slot0 + liquidity + both erc20 balances; univ2: getReserves + totalSupply. Hot pools (TVL ≥ $10k, GT-listed, or < 1h old) refresh every 60s; the whole catalog hourly (~280 aggregates); new pools immediately.
  • Pricing waterfall → TVL — GeckoTerminal token prices seed (ground truth while < 30 min old), then prices propagate through the deepest priced-side pool (≥ $300 depth so dust can't set prices), USDG ≈ $1 bootstraps before the first GT cycle. TVL = sum of priced sides (one side priced → 2×, flagged approximate). All REAL numbers are display/ranking only — never tx inputs.
  • Stats — GeckoTerminal top lists (network + uniswap-v2 + uniswap-v3, top 200 each, paced ≤ 30 calls/min free tier) every 5 min: 24h volume/txns + GT's own reserve figure. GT has no UP33 entry — UP33 stats stay on the frontend's dexscreener/goldsky path.
  • APIGET /api/pools?q=&proto=univ2,univ3&min_tvl=&sort=tvl|vol|created&limit=&offset= (response shape mirrors the frontend's PoolsData/PoolStat; bigints as strings; ready:false while the first backfill runs → frontend keeps its fallback), GET /api/tokens?q= (symbol/address autocomplete), GET /api/health (counts, cursors, rss).

Data lives in indexer/data/index.db (WAL SQLite); delete it to re-backfill from scratch — the kv cursors make every loop resumable.

The tuning above follows from the chain's scale, measured July 2026: ~100k+ univ3 pools growing ~20k/day (launchpad factories mint a pool per token), 11,640 univ2 pairs, ≥95% dust, 100ms blocks (~862k/day), and ~2.6M Swap events/day chain-wide — which is why volume comes from GeckoTerminal instead of self-indexed swaps (revisit with Envio HyperIndex, which supports chain 4663 natively, if self-computed fee/APR analytics are ever wanted). At this scale a full state sweep is ~1k multicall aggregates (~7 min hourly); the hot tier stays tiny because real TVL, not pool count, bounds it. If you re-measure the pool count, use the RPC-window scan — Blockscout's paged getLogs silently undercounts.

Chain reads

The app is a fully static SPA — it has no backend of its own. The browser talks directly to: the chain RPC (reads only; writes are signed and sent by the user's wallet), the KyberSwap aggregator, DexScreener, and the Goldsky subgraph. There is no database and no server-side state; limit-order tags live in each user's browser localStorage (device-local by design).

The only secret is a private RPC URL (RPC in .env) — Vite bakes env values into the JS bundle, so a public build must never have it set. One build serves every mode; the read transport resolves at runtime:

mode build chain reads
personal / local .env has RPC that URL, baked (keep the build private)
server + reverse proxy RPC unset same-origin /rpc → your proxy holds the key server-side
plain static hosting RPC unset /rpc probe fails → falls back to the public RPC (keyless, slower)

Wallet-facing chain metadata (wallet_addEthereumChain) always advertises the public RPC — a private key-bearing URL never reaches users' wallets.

On top of all modes, each user can bring their own RPC: the footer rpc: control accepts any http(s) JSON-RPC url, sanity-checks it with an eth_chainId probe (must be 4663), stores it in that browser's localStorage and applies on reload. A user-set endpoint takes priority over everything above; RESET returns to the deployment default.

Deploy

npm run build produces a static dist/ — hash routing needs no rewrite rules, so any static host works (CF Pages / Netlify / S3):

RPC="" npm run build   # RPC MUST be empty for any build you serve publicly

To keep a private RPC key server-side, serve dist/ behind a reverse proxy that terminates these same-origin paths. Nothing here is app-specific — any nginx / caddy / Cloudflare Worker will do:

path proxies to why
/rpc your JSON-RPC upstream key stays server-side; app auto-detects and uses it
/kyber https://aggregator-api.kyberswap.com build with KYBERSWAP_AGGREGATOR_API_BASE_URL=/kyber
/kyber-setting https://ks-setting.kyberswap.com token list
/dexscreener https://api.dexscreener.com UP33 TVL/volume stats
/goldsky https://api.goldsky.com UP33 v2 subgraph
/api your indexer process on :8787 uniswap pool discovery (optional)

Routing the data APIs through your own origin means the browser only ever talks to your origin + the chain RPC + wallet relays, so users on restrictive networks keep every feature. Recommended if you expose these publicly: rate-limit /rpc per-IP plus a global ceiling, and 403 requests carrying a foreign browser Origin so other sites can't burn your upstream quota through users' browsers.

vite.config.ts emulates every one of these proxies in dev and preview, so the server mode is testable locally without deploying anything:

RPC="" npm run build && npm run preview   # /rpc upstream stays in the node process

Two constraints worth knowing before you touch the build config:

  • Relative API bases need new URL(x, location.origin) — a bare new URL('/kyber/…') throws and silently kills all quotes.
  • viem's ccip module is the bundle's only lazy chunk on an error path, and it is imported inside every eth_call error before the selector check. It is deliberately pinned into the eager bundle (src/main.tsx) so a redeploy can't 404 it under an open tab and mask the real revert reason. Other stale lazy chunks (wallet SDKs, RainbowKit locales) are handled by a vite:preloadError guarded auto-reload. Serve old + new asset generations side by side across a deploy if you want open tabs to survive it.

Security

Threat model researched against real dApp incidents (BadgerDAO injected-script approval drain, Curve/CoW DNS hijacks, Ledger Connect Kit npm supply chain) and the OWASP Web3 attack-vector list. Architecture principle: static SPA + thin stateless reverse proxy = minimal attack surface — no accounts, no database, no server-side keys; a heavier backend would add attack surface, not safety.

Wallet-interaction safety (the money paths)

  • browser-wallet signing only; no key material anywhere in app, server, or storage
  • exact-amount approvals; four gates on opaque kyber calldata (see Safety rails); writes chainId-pinned with deadlines; native-route mins from fresh on-chain price
  • token picker rows always show the contract address (anti symbol-spoofing); contract directory lists full addresses linked to the explorer

XSS / injected-drainer defenses

  • ships with CSP script-src 'self' in mind: no inline scripts, no eval, no third-party or CDN scripts — everything self-hosted and content-hashed. React escaping only (no dangerouslySetInnerHTML), noreferrer on external links
  • recommended headers when self-hosting: X-Frame-Options DENY + frame-ancestors 'none' (clickjacking), nosniff, Referrer-Policy, Permissions-Policy (all sensors off), Cross-Origin-Opener-Policy: same-origin-allow-popups (wallet popups still work), frame-src limited to WalletConnect verify, HSTS at the edge
  • connect-src stays https:/wss: by design: the footer bring-your-own-RPC feature and wallet relays need it; the script-src wall is the real defense

Supply chain

  • dependencies exact-pinned (.npmrc save-exact) + lockfile — a compromised patch release can't slip in via re-install (Ledger-style attack)
  • npm audit on every dependency change. Known open advisory: ws DoS via wagmi's WalletConnect chain — server-side-only issue, browsers use native WebSocket; the fix is a wagmi v2→v3 major migration, deferred deliberately
  • zero analytics/trackers/third-party runtime scripts

If you self-host publicly, the frontend-integrity risk worth planning for is DNS/CDN hijack: fetch your own live site the way users do and byte-compare against your local build. A CSP with no inline scripts blocks injection at the browser level even if HTML were tampered in transit.

Known v1 limits

  • Native-path swaps are single-hop (direct pools); kyber path covers multi-hop routing.
  • UP33-native route with ETH input requires wrapping to WETH first (one click).
  • veUP locking / voting / bribes are read-only concerns for later versions.
  • Uniswap v2/v3 long-tail pools outside GeckoTerminal's top-200 lists show chain-derived TVL but no 24h volume (blank VOL/FEES/APR columns) — computing volume ourselves would mean indexing ~2.6M Swap events/day (measured); deliberately skipped.
  • Uniswap v2 POSITIONS management (LP-token balances, remove-liquidity) is not wired yet — POOLS browse + add-liquidity only. v2 LPs are plain ERC-20s, so wallet-level tracking works meanwhile.
  • Creating a NEW univ3 pool (createAndInitializePoolIfNecessary) is not wired up — mint into existing pools only.
  • Uniswap v4 is live on the chain but not integrated (addresses noted in src/config/addresses.ts; the indexer's catalog model extends to v4's Initialize events if/when wanted).

Disclaimer

This software is provided as-is under the MIT license, with no warranty of any kind. It is an unaudited interface to third-party smart contracts that this project does not control, on a chain whose protocol parameters are Safe-controlled and can change at any time. Interacting with DeFi protocols can result in total loss of funds. You are solely responsible for reviewing the code, verifying every contract address, and for any transaction your wallet signs. Nothing here is financial advice.