mirror of
https://github.com/labrinyang/lp-terminal.git
synced 2026-07-27 21:27:43 +00:00
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.
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
# LP TERMINAL — copy to `.env` in the REPO ROOT (next to package.json).
|
||||||
|
# Vite reads it via envDir/envPrefix (see vite.config.ts); the indexer reads
|
||||||
|
# `RPC` from the same file. Every key here is OPTIONAL — with no .env at all
|
||||||
|
# the app runs against the chain's public RPC and the public Kyber aggregator.
|
||||||
|
|
||||||
|
# Private JSON-RPC URL for Robinhood Chain (chainId 4663), e.g. a provider URL
|
||||||
|
# with its key path. SECRET: Vite BAKES this into the JS bundle, so set it only
|
||||||
|
# for personal/local builds. A build you serve publicly must leave RPC unset —
|
||||||
|
# it then reads through same-origin /rpc, or falls back to the public RPC.
|
||||||
|
# Unset -> https://rpc.mainnet.chain.robinhood.com (keyless, slower).
|
||||||
|
RPC=
|
||||||
|
|
||||||
|
# KyberSwap aggregator base. Absolute URL = browser calls Kyber directly.
|
||||||
|
# A path like /kyber = route through your own same-origin reverse proxy.
|
||||||
|
KYBERSWAP_AGGREGATOR_API_BASE_URL=https://aggregator-api.kyberswap.com
|
||||||
|
KYBERSWAP_CHAIN=robinhood
|
||||||
|
|
||||||
|
# WHITELIST: the only address swap calldata is ever sent to. The app hard-fails
|
||||||
|
# a quote whose routerAddress differs from this. Change only if Kyber migrates.
|
||||||
|
KYBERSWAP_ROUTER_ADDRESS=0x6131B5fae19EA4f9D964eAc0408E4408b66337b5
|
||||||
|
|
||||||
|
# Optional platform fee on Kyber swaps, charged on the output token and paid by
|
||||||
|
# the Kyber router. BOTH must be set to activate; leave blank for no fee.
|
||||||
|
# KYBERSWAP_FEE_BPS=10
|
||||||
|
# KYBERSWAP_FEE_RECEIVER=0x...
|
||||||
|
|
||||||
|
# Optional: only WalletConnect QR pairing needs this. Injected wallets
|
||||||
|
# (MetaMask / Rabby / OKX) work without it. Get one at cloud.walletconnect.com
|
||||||
|
# VITE_WALLETCONNECT_PROJECT_ID=
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
indexer/data
|
||||||
|
indexer-dist
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
*.log
|
||||||
|
.vite
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 labrinyang
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# 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](#known-v1-limits). MIT licensed — see [Disclaimer](#disclaimer)
|
||||||
|
before you point it at real money.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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](#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: `1–3` 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 APR` — **unstaked** 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 APR` — **staked** 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.
|
||||||
|
- **API** — `GET /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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' fill='%230a0e0b'/><rect x='3' y='7' width='10' height='6' fill='%234af626'/><rect x='3' y='3' width='10' height='2' fill='%234af626'/></svg>"
|
||||||
|
/>
|
||||||
|
<title>LP TERMINAL</title>
|
||||||
|
<script>
|
||||||
|
// apply the saved theme before first paint (no flash). ?theme= is a
|
||||||
|
// view-only override (screenshots/sharing) — it is not persisted.
|
||||||
|
try {
|
||||||
|
var m = location.search.match(/[?&]theme=(\w+)/)
|
||||||
|
var t = (m && m[1]) || localStorage.getItem('up33.theme.v1')
|
||||||
|
if (/^(mono|phosphor|amber|ice|violet)$/.test(t)) document.documentElement.dataset.theme = t
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
// Read-only HTTP API. Response shapes mirror the frontend's PoolsData /
|
||||||
|
// PoolStat structures so the POOLS tab maps rows 1:1 (bigints travel as
|
||||||
|
// strings). Served same-origin in production (nginx /api → this) and through
|
||||||
|
// the vite dev/preview proxy locally.
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||||
|
import { PORT, log, now } from './config'
|
||||||
|
import { db, kvGet, poolCounts } from './store'
|
||||||
|
|
||||||
|
const JSONH = { 'content-type': 'application/json; charset=utf-8' }
|
||||||
|
|
||||||
|
type Params = URLSearchParams
|
||||||
|
|
||||||
|
const PROTOS = new Set(['univ2', 'univ3'])
|
||||||
|
const HEX40 = /^0x[0-9a-f]{40}$/
|
||||||
|
|
||||||
|
function poolsWhere(params: Params): { where: string; args: (string | number)[] } {
|
||||||
|
const clauses: string[] = []
|
||||||
|
const args: (string | number)[] = []
|
||||||
|
|
||||||
|
const proto = (params.get('proto') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => PROTOS.has(s))
|
||||||
|
if (proto.length) {
|
||||||
|
clauses.push(`p.proto IN (${proto.map(() => '?').join(',')})`)
|
||||||
|
args.push(...proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
const minTvl = Number(params.get('min_tvl'))
|
||||||
|
if (Number.isFinite(minTvl) && minTvl > 0) {
|
||||||
|
clauses.push('s.tvl_usd >= ?')
|
||||||
|
args.push(minTvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = (params.get('q') ?? '').trim().toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
if (HEX40.test(q)) {
|
||||||
|
clauses.push('(p.address = ? OR p.token0 = ? OR p.token1 = ?)')
|
||||||
|
args.push(q, q, q)
|
||||||
|
} else if (q.includes('/')) {
|
||||||
|
// pair search: "weth/usdg" — both sides must match (either orientation)
|
||||||
|
const [a, b] = q.split('/', 2).map((s) => s.trim())
|
||||||
|
const side = `SELECT address FROM tokens WHERE symbol LIKE ?`
|
||||||
|
clauses.push(
|
||||||
|
`((p.token0 IN (${side}) AND p.token1 IN (${side})) OR (p.token0 IN (${side}) AND p.token1 IN (${side})))`,
|
||||||
|
)
|
||||||
|
args.push(a + '%', b + '%', b + '%', a + '%')
|
||||||
|
} else {
|
||||||
|
const side = `SELECT address FROM tokens WHERE symbol LIKE ?`
|
||||||
|
clauses.push(`(p.token0 IN (${side}) OR p.token1 IN (${side}))`)
|
||||||
|
args.push(q + '%', q + '%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', args }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER: Record<string, string> = {
|
||||||
|
tvl: 'ORDER BY (s.tvl_usd IS NULL), s.tvl_usd DESC',
|
||||||
|
vol: 'ORDER BY (st.vol24h_usd IS NULL), st.vol24h_usd DESC',
|
||||||
|
created: 'ORDER BY (p.created_block IS NULL), p.created_block DESC, p.pair_index DESC',
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoolOut = Record<string, unknown>
|
||||||
|
|
||||||
|
function getPools(params: Params) {
|
||||||
|
const { where, args } = poolsWhere(params)
|
||||||
|
const order = ORDER[params.get('sort') ?? 'tvl'] ?? ORDER.tvl
|
||||||
|
const limit = Math.min(Math.max(Number(params.get('limit')) || 100, 1), 500)
|
||||||
|
const offset = Math.min(Math.max(Number(params.get('offset')) || 0, 0), 20_000)
|
||||||
|
|
||||||
|
const base = `FROM pools p LEFT JOIN pool_state s ON s.address = p.address LEFT JOIN pool_stats st ON st.address = p.address ${where}`
|
||||||
|
const count = (db.prepare(`SELECT COUNT(*) AS n ${base}`).get(...args) as { n: number }).n
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.address, p.proto, p.token0, p.token1, p.fee_ppm, p.tick_spacing, p.created_block,
|
||||||
|
s.sqrt_price, s.tick, s.liquidity, s.reserve0, s.reserve1, s.total_supply,
|
||||||
|
s.tvl_usd, s.tvl_approx, s.updated AS state_updated,
|
||||||
|
st.vol24h_usd, st.txns24h, st.liq_usd, st.source AS stats_source
|
||||||
|
${base} ${order} LIMIT ? OFFSET ?`,
|
||||||
|
)
|
||||||
|
.all(...args, limit, offset) as Record<string, unknown>[]
|
||||||
|
|
||||||
|
const tokenAddrs = new Set<string>()
|
||||||
|
const pools: PoolOut[] = rows.map((r) => {
|
||||||
|
tokenAddrs.add(r.token0 as string)
|
||||||
|
tokenAddrs.add(r.token1 as string)
|
||||||
|
return {
|
||||||
|
proto: r.proto,
|
||||||
|
address: r.address,
|
||||||
|
token0: r.token0,
|
||||||
|
token1: r.token1,
|
||||||
|
feePpm: r.fee_ppm,
|
||||||
|
tickSpacing: r.tick_spacing,
|
||||||
|
createdBlock: r.created_block,
|
||||||
|
sqrtPriceX96: r.sqrt_price,
|
||||||
|
tick: r.tick,
|
||||||
|
liquidity: r.liquidity,
|
||||||
|
reserve0: r.reserve0 ?? '0',
|
||||||
|
reserve1: r.reserve1 ?? '0',
|
||||||
|
totalSupply: r.total_supply,
|
||||||
|
tvlUsd: r.tvl_usd,
|
||||||
|
tvlApprox: r.tvl_approx === 1,
|
||||||
|
vol24hUsd: r.vol24h_usd,
|
||||||
|
txns24h: r.txns24h,
|
||||||
|
gtLiqUsd: r.liq_usd,
|
||||||
|
statsSource: r.stats_source,
|
||||||
|
stateUpdated: r.state_updated,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokens: Record<string, unknown> = {}
|
||||||
|
if (tokenAddrs.size) {
|
||||||
|
const list = [...tokenAddrs]
|
||||||
|
const trs = db
|
||||||
|
.prepare(`SELECT address, symbol, decimals, price_usd FROM tokens WHERE address IN (${list.map(() => '?').join(',')})`)
|
||||||
|
.all(...list) as { address: string; symbol: string; decimals: number; price_usd: number | null }[]
|
||||||
|
for (const t of trs) tokens[t.address] = { address: t.address, symbol: t.symbol, decimals: t.decimals, priceUsd: t.price_usd }
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = Object.fromEntries(poolCounts().map((c) => [c.proto, c.n]))
|
||||||
|
return { ready: kvGet('ready') === '1', asof: now(), totals, count, pools, tokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokens(params: Params) {
|
||||||
|
const q = (params.get('q') ?? '').trim().toLowerCase()
|
||||||
|
if (!q) return { tokens: [] }
|
||||||
|
const rows = HEX40.test(q)
|
||||||
|
? db.prepare('SELECT address, symbol, decimals, price_usd FROM tokens WHERE address = ?').all(q)
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.address, t.symbol, t.decimals, t.price_usd,
|
||||||
|
(SELECT COUNT(*) FROM pools p WHERE p.token0 = t.address OR p.token1 = t.address) AS pools
|
||||||
|
FROM tokens t WHERE t.symbol LIKE ? ORDER BY pools DESC LIMIT 20`,
|
||||||
|
)
|
||||||
|
.all(q + '%')
|
||||||
|
return { tokens: rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHealth() {
|
||||||
|
const totals = Object.fromEntries(poolCounts().map((c) => [c.proto, c.n]))
|
||||||
|
const tokens = (db.prepare('SELECT COUNT(*) AS n FROM tokens').get() as { n: number }).n
|
||||||
|
const priced = (db.prepare('SELECT COUNT(*) AS n FROM tokens WHERE price_usd > 0').get() as { n: number }).n
|
||||||
|
const tvl = (db.prepare('SELECT COUNT(*) AS n FROM pool_state WHERE tvl_usd IS NOT NULL').get() as { n: number }).n
|
||||||
|
return {
|
||||||
|
ready: kvGet('ready') === '1',
|
||||||
|
asof: now(),
|
||||||
|
pools: totals,
|
||||||
|
tokens,
|
||||||
|
pricedTokens: priced,
|
||||||
|
tvlPools: tvl,
|
||||||
|
v3Cursor: Number(kvGet('v3_cursor') ?? 0),
|
||||||
|
v2Count: Number(kvGet('v2_count') ?? 0),
|
||||||
|
rssMb: Math.round(process.memoryUsage.rss() / 1e6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startApi(): void {
|
||||||
|
const srv = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const started = Date.now()
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://indexer')
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.writeHead(405, JSONH)
|
||||||
|
res.end('{"error":"GET only"}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let body: unknown
|
||||||
|
let cache = 'public, max-age=10'
|
||||||
|
if (url.pathname === '/api/pools') body = getPools(url.searchParams)
|
||||||
|
else if (url.pathname === '/api/tokens') body = getTokens(url.searchParams)
|
||||||
|
else if (url.pathname === '/api/health') {
|
||||||
|
body = getHealth()
|
||||||
|
cache = 'no-store'
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, JSONH)
|
||||||
|
res.end('{"error":"not found"}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.writeHead(200, { ...JSONH, 'cache-control': cache })
|
||||||
|
res.end(JSON.stringify(body))
|
||||||
|
if (Date.now() - started > 500) log(`[api] slow ${url.pathname} ${Date.now() - started}ms`)
|
||||||
|
} catch (e) {
|
||||||
|
res.writeHead(500, JSONH)
|
||||||
|
res.end(JSON.stringify({ error: String(e) }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv.listen(PORT, () => log(`[api] listening on :${PORT}`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Pool catalog — the authoritative list, built ONLY from the official
|
||||||
|
// factories themselves (events / enumeration). Third-party APIs never admit a
|
||||||
|
// pool here, they only enrich pools that already exist (spoofing is therefore
|
||||||
|
// structurally impossible: a fork pool is simply never in the table).
|
||||||
|
//
|
||||||
|
// univ3: PoolCreated logs. Backfill via Blockscout's etherscan-style getLogs
|
||||||
|
// (no block-range cap, 1000/page, fromBlock-cursor pagination —
|
||||||
|
// measured ~22 pages for the full 21,979-pool history), then a plain
|
||||||
|
// RPC getLogs tail in ≤9k-block windows (Alchemy caps at 10k).
|
||||||
|
// univ2: the factory keeps an allPairs array — enumeration IS the catalog.
|
||||||
|
// Backfill and tail are the same code path: read allPairsLength,
|
||||||
|
// fetch any indices we haven't seen.
|
||||||
|
import { parseAbiItem, toEventSelector } from 'viem'
|
||||||
|
import { uniV2FactoryAbi, uniV2PairAbi } from '../src/abi'
|
||||||
|
import { BLOCKSCOUT, log, sleep, UNI } from './config'
|
||||||
|
import { mc, ok, pc } from './rpc'
|
||||||
|
import { insertPool, kvGet, kvSet, tx } from './store'
|
||||||
|
|
||||||
|
const POOL_CREATED = parseAbiItem(
|
||||||
|
'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)',
|
||||||
|
)
|
||||||
|
const POOL_CREATED_TOPIC = toEventSelector(POOL_CREATED)
|
||||||
|
|
||||||
|
const hexInt = (x: string | number) => (typeof x === 'number' ? x : parseInt(x, 16))
|
||||||
|
const addrOfTopic = (t: string) => ('0x' + t.slice(-40)).toLowerCase()
|
||||||
|
|
||||||
|
async function bsJson(url: string): Promise<Record<string, unknown>> {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
headers: { accept: 'application/json', 'user-agent': 'up33-lp-indexer/0.1' },
|
||||||
|
})
|
||||||
|
const text = await r.text()
|
||||||
|
if (text.trim()) return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
/* retry below */
|
||||||
|
}
|
||||||
|
await sleep(1_200 * (i + 1))
|
||||||
|
}
|
||||||
|
throw new Error('blockscout: no response after retries')
|
||||||
|
}
|
||||||
|
|
||||||
|
type BsLog = { topics: string[]; data: string; blockNumber: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time full-history PoolCreated scan (resumable via the v3_cursor kv).
|
||||||
|
* Primary source is Blockscout (no range cap, ~22 pages for full history);
|
||||||
|
* if it flakes persistently the scan falls back to windowed RPC getLogs from
|
||||||
|
* the same cursor — slower (~1.2k windows) but unconditionally available.
|
||||||
|
*/
|
||||||
|
export async function backfillV3(): Promise<number> {
|
||||||
|
if (kvGet('v3_backfilled')) return 0
|
||||||
|
let cursor = Number(kvGet('v3_cursor') ?? 0)
|
||||||
|
let added = 0
|
||||||
|
let flakes = 0
|
||||||
|
// INDEXER_BACKFILL=rpc skips Blockscout entirely — useful when it throttles
|
||||||
|
// (observed 2026-07-16: page pace degraded 1min → 6min mid-backfill). With a
|
||||||
|
// private RPC the windowed scan is deterministic (~770 windows for full
|
||||||
|
// history) and resumes from the same cursor.
|
||||||
|
if (process.env.INDEXER_BACKFILL === 'rpc') {
|
||||||
|
log(`[catalog] v3 backfill via RPC windows from blk ${cursor} (INDEXER_BACKFILL=rpc)`)
|
||||||
|
const head = Number(await pc.getBlockNumber())
|
||||||
|
added = (await scanV3Windows(cursor, head)).length
|
||||||
|
kvSet('v3_cursor', String(head))
|
||||||
|
kvSet('v3_backfilled', '1')
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
const j = await bsJson(
|
||||||
|
`${BLOCKSCOUT}/api?module=logs&action=getLogs&fromBlock=${cursor}&toBlock=latest&address=${UNI.V3_FACTORY}&topic0=${POOL_CREATED_TOPIC}`,
|
||||||
|
).catch(() => ({ status: '0', message: 'no response' }) as Record<string, unknown>)
|
||||||
|
if (j.status !== '1') {
|
||||||
|
if (/no records/i.test(String(j.message))) break
|
||||||
|
if (++flakes >= 6) {
|
||||||
|
// Blockscout is down/unhappy — finish the remaining range over RPC
|
||||||
|
log(`[catalog] blockscout flaking ("${j.message}") — RPC-window fallback from blk ${cursor}`)
|
||||||
|
const head = Number(await pc.getBlockNumber())
|
||||||
|
added += (await scanV3Windows(cursor, head)).length
|
||||||
|
kvSet('v3_cursor', String(head))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await sleep(2_000 * flakes)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flakes = 0
|
||||||
|
const logs = j.result as BsLog[]
|
||||||
|
tx(() => {
|
||||||
|
for (const l of logs) {
|
||||||
|
// topics: [sig, token0, token1, fee]; data: [tickSpacing:int24, pool:address]
|
||||||
|
const tickSpacing = Number(BigInt.asIntN(24, BigInt('0x' + l.data.slice(2, 66))))
|
||||||
|
if (
|
||||||
|
insertPool({
|
||||||
|
address: addrOfTopic(l.data.slice(66, 130)),
|
||||||
|
proto: 'univ3',
|
||||||
|
token0: addrOfTopic(l.topics[1]),
|
||||||
|
token1: addrOfTopic(l.topics[2]),
|
||||||
|
feePpm: hexInt(l.topics[3]),
|
||||||
|
tickSpacing,
|
||||||
|
createdBlock: hexInt(l.blockNumber),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const last = hexInt(logs[logs.length - 1].blockNumber)
|
||||||
|
kvSet('v3_cursor', String(last))
|
||||||
|
log(`[catalog] v3 backfill +${added} pools (cursor blk ${last})`)
|
||||||
|
if (logs.length < 1000) break
|
||||||
|
cursor = last // overlap the last block; PK dedupes
|
||||||
|
await sleep(300)
|
||||||
|
}
|
||||||
|
kvSet('v3_backfilled', '1')
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
/** windowed RPC getLogs scan (≤9k blocks per request — under Alchemy's 10k cap) */
|
||||||
|
async function scanV3Windows(from: number, to: number): Promise<string[]> {
|
||||||
|
const fresh: string[] = []
|
||||||
|
let logged = 0
|
||||||
|
for (let lo = from; lo <= to; lo += 9_001) {
|
||||||
|
const hi = Math.min(lo + 9_000, to)
|
||||||
|
const logs = await pc.getLogs({
|
||||||
|
address: UNI.V3_FACTORY,
|
||||||
|
event: POOL_CREATED,
|
||||||
|
fromBlock: BigInt(lo),
|
||||||
|
toBlock: BigInt(hi),
|
||||||
|
})
|
||||||
|
for (const l of logs) {
|
||||||
|
const a = l.args
|
||||||
|
if (!a.pool || !a.token0 || !a.token1 || a.fee === undefined || a.tickSpacing === undefined) continue
|
||||||
|
if (
|
||||||
|
insertPool({
|
||||||
|
address: a.pool.toLowerCase(),
|
||||||
|
proto: 'univ3',
|
||||||
|
token0: a.token0,
|
||||||
|
token1: a.token1,
|
||||||
|
feePpm: a.fee,
|
||||||
|
tickSpacing: a.tickSpacing,
|
||||||
|
createdBlock: Number(l.blockNumber),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fresh.push(a.pool.toLowerCase())
|
||||||
|
}
|
||||||
|
if (to - from > 100_000 && ++logged % 100 === 0)
|
||||||
|
log(`[catalog] rpc scan blk ${hi}/${to} (+${fresh.length})`)
|
||||||
|
}
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RPC tail from the stored cursor to head; returns newly added pool addresses */
|
||||||
|
export async function tailV3(): Promise<string[]> {
|
||||||
|
const head = Number(await pc.getBlockNumber())
|
||||||
|
const from = Math.max(0, Number(kvGet('v3_cursor') ?? head - 2_000) - 120) // ~12s overlap
|
||||||
|
const fresh = await scanV3Windows(from, head)
|
||||||
|
kvSet('v3_cursor', String(head))
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* univ2 catalog sync (backfill == tail): fetch allPairs indices we haven't
|
||||||
|
* seen yet. The cursor only advances past indices that fully resolved, so a
|
||||||
|
* partial multicall failure is retried on the next tick.
|
||||||
|
*/
|
||||||
|
export async function syncV2(): Promise<string[]> {
|
||||||
|
const count = Number(
|
||||||
|
await pc.readContract({ abi: uniV2FactoryAbi, address: UNI.V2_FACTORY, functionName: 'allPairsLength' }),
|
||||||
|
)
|
||||||
|
let known = Number(kvGet('v2_count') ?? 0)
|
||||||
|
if (count <= known) return []
|
||||||
|
const fresh: string[] = []
|
||||||
|
while (known < count) {
|
||||||
|
const n = Math.min(2_000, count - known) // 2k pairs per round = 5 + 10 aggregates
|
||||||
|
const idx = Array.from({ length: n }, (_, i) => known + i)
|
||||||
|
const pairRes = await mc(
|
||||||
|
idx.map((i) => ({ abi: uniV2FactoryAbi, address: UNI.V2_FACTORY, functionName: 'allPairs', args: [BigInt(i)] })),
|
||||||
|
)
|
||||||
|
const pairs: string[] = []
|
||||||
|
for (const r of pairRes) {
|
||||||
|
const a = ok<string>(r)
|
||||||
|
if (!a) break // stop at first failure — cursor advances only past successes
|
||||||
|
pairs.push(a)
|
||||||
|
}
|
||||||
|
if (!pairs.length) break
|
||||||
|
const tokRes = await mc(
|
||||||
|
pairs.flatMap((p) => [
|
||||||
|
{ abi: uniV2PairAbi, address: p as `0x${string}`, functionName: 'token0' },
|
||||||
|
{ abi: uniV2PairAbi, address: p as `0x${string}`, functionName: 'token1' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
let done = 0
|
||||||
|
tx(() => {
|
||||||
|
for (let i = 0; i < pairs.length; i++) {
|
||||||
|
const t0 = ok<string>(tokRes[i * 2])
|
||||||
|
const t1 = ok<string>(tokRes[i * 2 + 1])
|
||||||
|
if (!t0 || !t1) break
|
||||||
|
if (
|
||||||
|
insertPool({
|
||||||
|
address: pairs[i].toLowerCase(),
|
||||||
|
proto: 'univ2',
|
||||||
|
token0: t0,
|
||||||
|
token1: t1,
|
||||||
|
feePpm: 3_000, // vanilla v2: fixed 0.30%
|
||||||
|
pairIndex: known + i,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fresh.push(pairs[i].toLowerCase())
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!done) break
|
||||||
|
known += done
|
||||||
|
kvSet('v2_count', String(known))
|
||||||
|
if (known < count) log(`[catalog] v2 sync ${known}/${count}`)
|
||||||
|
}
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Indexer constants + tuning. Contract addresses come from the shared frontend
|
||||||
|
// config — src/config/addresses.ts and src/abi are pure modules and load fine
|
||||||
|
// under node/tsx. src/config/env.ts does NOT (import.meta.env is vite-only),
|
||||||
|
// which is why the public RPC is duplicated here instead of imported.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
export { ADDR, UNI } from '../src/config/addresses'
|
||||||
|
|
||||||
|
export const PUBLIC_RPC = 'https://rpc.mainnet.chain.robinhood.com'
|
||||||
|
export const BLOCKSCOUT = 'https://robinhoodchain.blockscout.com'
|
||||||
|
export const GT = 'https://api.geckoterminal.com/api/v2'
|
||||||
|
|
||||||
|
export const PORT = Number(process.env.INDEXER_PORT || 8787)
|
||||||
|
export const DB_PATH =
|
||||||
|
process.env.INDEXER_DB || fileURLToPath(new URL('./data/index.db', import.meta.url))
|
||||||
|
|
||||||
|
export const TUNE = {
|
||||||
|
tailMs: 10_000, // factory tail + v2 allPairsLength poll
|
||||||
|
hotSweepMs: 60_000, // state refresh for hot pools
|
||||||
|
fullSweepMs: 3_600_000, // state refresh for ACTIVE pools (≥$100 TVL or <48h old)
|
||||||
|
censusMs: 21_600_000, // 6h full-catalog dust census (~114k pools and growing)
|
||||||
|
statsMs: 300_000, // GeckoTerminal enrichment cycle
|
||||||
|
gtPaceMs: 2_600, // ≥2.6s between GT calls (free tier: 30/min)
|
||||||
|
batch: 400, // calls per multicall aggregate
|
||||||
|
batchGapMs: 40, // pause between aggregates (gentle on the RPC)
|
||||||
|
hotTvlUsd: 10_000, // pools at/above this TVL refresh every hotSweepMs
|
||||||
|
minDepthUsd: 300, // min priced-side USD depth to propagate a price through a pool
|
||||||
|
gtFreshSecs: 1_800, // GT prices younger than this are never overwritten by propagation
|
||||||
|
}
|
||||||
|
|
||||||
|
/** repo-root .env `RPC` (SECRET — never log/print it). Fallback: key-free public RPC. */
|
||||||
|
export function rpcUrl(): string {
|
||||||
|
const env = process.env.RPC?.trim()
|
||||||
|
if (env) return env
|
||||||
|
try {
|
||||||
|
const text = readFileSync(new URL('../.env', import.meta.url), 'utf8')
|
||||||
|
const m = text.match(/^\s*RPC\s*=\s*(\S+)\s*$/m)
|
||||||
|
if (m) return m[1]
|
||||||
|
} catch {
|
||||||
|
/* no repo .env — public RPC below */
|
||||||
|
}
|
||||||
|
return PUBLIC_RPC
|
||||||
|
}
|
||||||
|
|
||||||
|
export const now = () => Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
/** terminal-style timestamped log line */
|
||||||
|
export const log = (...a: unknown[]) =>
|
||||||
|
console.log(new Date().toISOString().slice(11, 19), ...a)
|
||||||
|
|
||||||
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
// UP33 LP-terminal pool indexer — catalog (factory events/enumeration) +
|
||||||
|
// on-chain state sweeps + GT enrichment, served over a tiny read-only API.
|
||||||
|
// Run: `npm run indexer` (tsx). Data lives in indexer/data/index.db (SQLite).
|
||||||
|
//
|
||||||
|
// Boot: backfill → token meta → full state sweep → GT cycle → reprice → ready.
|
||||||
|
// Loops: tail 10s · hot sweep 60s · full sweep 60min · GT stats 5min.
|
||||||
|
// The API starts listening immediately; `ready:false` in responses tells the
|
||||||
|
// frontend to keep using its client-side fallback until the first pass lands.
|
||||||
|
import { log, PORT, TUNE } from './config'
|
||||||
|
import { usingPrivateRpc } from './rpc'
|
||||||
|
import { backfillV3, syncV2, tailV3 } from './catalog'
|
||||||
|
import { computeTvlFor, ensureTokenMeta, reprice, sweepState } from './state'
|
||||||
|
import { gtCycle } from './stats'
|
||||||
|
import { activeAddrs, allPoolAddrs, db, hotAddrs, kvGet, kvSet, poolCounts } from './store'
|
||||||
|
import { startApi } from './api'
|
||||||
|
|
||||||
|
/** setTimeout-chained loop — never overlaps itself, logs failures and keeps going */
|
||||||
|
function loop(name: string, ms: number, fn: () => Promise<void>): void {
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} catch (e) {
|
||||||
|
log(`[${name}] error:`, String(e).slice(0, 200))
|
||||||
|
}
|
||||||
|
setTimeout(tick, ms)
|
||||||
|
}
|
||||||
|
setTimeout(tick, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timed = async <T,>(fn: () => Promise<T>): Promise<[T, number]> => {
|
||||||
|
const t0 = Date.now()
|
||||||
|
const r = await fn()
|
||||||
|
return [r, Date.now() - t0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
log('up33 lp-indexer starting —', usingPrivateRpc ? 'rpc: private (.env)' : 'rpc: public')
|
||||||
|
startApi()
|
||||||
|
|
||||||
|
const [addedV3, msV3] = await timed(backfillV3)
|
||||||
|
if (addedV3 > 0 || !kvGet('v3_boot_logged')) {
|
||||||
|
log(`[catalog] univ3 backfill done: +${addedV3} pools (${(msV3 / 1000).toFixed(0)}s)`)
|
||||||
|
kvSet('v3_boot_logged', '1')
|
||||||
|
}
|
||||||
|
const [freshV2, msV2] = await timed(syncV2)
|
||||||
|
if (freshV2.length) log(`[catalog] univ2 sync: +${freshV2.length} pairs (${(msV2 / 1000).toFixed(0)}s)`)
|
||||||
|
log('[catalog]', poolCounts().map((c) => `${c.proto}=${c.n}`).join(' '))
|
||||||
|
|
||||||
|
const [metaN, msMeta] = await timed(ensureTokenMeta)
|
||||||
|
if (metaN) log(`[tokens] metadata fetched for ${metaN} tokens (${(msMeta / 1000).toFixed(0)}s)`)
|
||||||
|
|
||||||
|
const all = allPoolAddrs()
|
||||||
|
const [, msSweep] = await timed(() => sweepState(all))
|
||||||
|
log(`[sweep] full ${all.length} pools (${(msSweep / 1000).toFixed(0)}s)`)
|
||||||
|
|
||||||
|
await gtCycle().catch((e) => log('[stats] gt cycle failed:', String(e).slice(0, 120)))
|
||||||
|
const pr = reprice()
|
||||||
|
log(`[price] ${pr.priced} tokens priced · tvl on ${pr.tvlPools} pools`)
|
||||||
|
|
||||||
|
kvSet('ready', '1')
|
||||||
|
log(`READY — http://localhost:${PORT}/api/health`)
|
||||||
|
|
||||||
|
loop('tail', TUNE.tailMs, async () => {
|
||||||
|
const fresh = [...(await tailV3()), ...(await syncV2())]
|
||||||
|
if (fresh.length) {
|
||||||
|
log(`[tail] ${fresh.length} new pools`)
|
||||||
|
await ensureTokenMeta()
|
||||||
|
await sweepState(fresh)
|
||||||
|
computeTvlFor(fresh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
loop('hot', TUNE.hotSweepMs, async () => {
|
||||||
|
const hot = hotAddrs()
|
||||||
|
await sweepState(hot)
|
||||||
|
computeTvlFor(hot)
|
||||||
|
})
|
||||||
|
loop('active', TUNE.fullSweepMs, async () => {
|
||||||
|
const addrs = activeAddrs()
|
||||||
|
const [, ms] = await timed(() => sweepState(addrs))
|
||||||
|
const p = reprice()
|
||||||
|
log(`[sweep] active ${addrs.length} pools (${(ms / 1000).toFixed(0)}s) · ${p.priced} tokens priced · tvl on ${p.tvlPools}`)
|
||||||
|
})
|
||||||
|
loop('census', TUNE.censusMs, async () => {
|
||||||
|
const addrs = allPoolAddrs()
|
||||||
|
const [, ms] = await timed(() => sweepState(addrs))
|
||||||
|
const p = reprice()
|
||||||
|
log(`[sweep] census ${addrs.length} pools (${(ms / 1000).toFixed(0)}s) · tvl on ${p.tvlPools}`)
|
||||||
|
})
|
||||||
|
loop('stats', TUNE.statsMs, async () => {
|
||||||
|
await gtCycle()
|
||||||
|
reprice()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
log('shutting down')
|
||||||
|
db.close()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
db.close()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
boot().catch((e) => {
|
||||||
|
log('FATAL boot:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createPublicClient, defineChain, http, type PublicClient } from 'viem'
|
||||||
|
import { log, PUBLIC_RPC, TUNE, rpcUrl, sleep } from './config'
|
||||||
|
|
||||||
|
// duplicated from src/config/chain.ts — that module imports src/config/env.ts
|
||||||
|
// (import.meta.env, vite-only) so it can't be loaded under node
|
||||||
|
const robinhood = defineChain({
|
||||||
|
id: 4663,
|
||||||
|
name: 'Robinhood Chain',
|
||||||
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||||
|
rpcUrls: { default: { http: [PUBLIC_RPC] } },
|
||||||
|
contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = rpcUrl()
|
||||||
|
export const usingPrivateRpc = url !== PUBLIC_RPC
|
||||||
|
// timeout is deliberately tight: a healthy 400-call aggregate answers in 2-4s
|
||||||
|
// (measured 2026-07-16); a stalled attempt should fail fast and retry, not
|
||||||
|
// pin the whole boot for 30s. Bad chunks degrade to sub-chunks in mc().
|
||||||
|
export const pc: PublicClient = createPublicClient({
|
||||||
|
chain: robinhood,
|
||||||
|
transport: http(url, { timeout: 10_000, retryCount: 2, retryDelay: 400 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** error text safe to log — the RPC url (secret) is redacted */
|
||||||
|
const redact = (e: unknown) =>
|
||||||
|
String(e instanceof Error ? `${e.name}: ${e.message.split('\n')[0]}` : e)
|
||||||
|
.replaceAll(url, '<rpc>')
|
||||||
|
.slice(0, 120)
|
||||||
|
|
||||||
|
// loose call shape — abi fragments come from parseAbi, results are narrowed by ok<T>()
|
||||||
|
export type Call = { abi: unknown; address: `0x${string}`; functionName: string; args?: unknown[] }
|
||||||
|
export type McRes = { status: 'success' | 'failure'; result?: unknown }
|
||||||
|
|
||||||
|
const agg = async (chunk: Call[]): Promise<McRes[]> =>
|
||||||
|
(await pc.multicall({ contracts: chunk as never, batchSize: 250_000 })) as McRes[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunked multicall: fixed TUNE.batch calls per aggregate3 (batchSize is set
|
||||||
|
* high so viem never sub-chunks by calldata bytes), allowFailure semantics,
|
||||||
|
* gentle pacing between chunks. A failing chunk is retried once, then split
|
||||||
|
* into 100-call sub-chunks so one bad call can only take 100 results down
|
||||||
|
* with it — mc() never throws, it returns per-call failures instead.
|
||||||
|
*/
|
||||||
|
export async function mc(calls: Call[]): Promise<McRes[]> {
|
||||||
|
const out: McRes[] = []
|
||||||
|
for (let i = 0; i < calls.length; i += TUNE.batch) {
|
||||||
|
const chunk = calls.slice(i, i + TUNE.batch)
|
||||||
|
const t0 = Date.now()
|
||||||
|
try {
|
||||||
|
out.push(...(await agg(chunk)))
|
||||||
|
} catch (e) {
|
||||||
|
log('[rpc] chunk failed, retrying:', redact(e))
|
||||||
|
await sleep(600)
|
||||||
|
try {
|
||||||
|
out.push(...(await agg(chunk)))
|
||||||
|
} catch {
|
||||||
|
for (let j = 0; j < chunk.length; j += 100) {
|
||||||
|
const part = chunk.slice(j, j + 100)
|
||||||
|
try {
|
||||||
|
out.push(...(await agg(part)))
|
||||||
|
} catch (e2) {
|
||||||
|
log(`[rpc] dropped ${part.length}-call sub-chunk:`, redact(e2))
|
||||||
|
out.push(...part.map(() => ({ status: 'failure' as const })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ms = Date.now() - t0
|
||||||
|
if (ms > 8_000) log(`[rpc] slow chunk: ${ms}ms (${chunk.length} calls)`)
|
||||||
|
if (i + TUNE.batch < calls.length) await sleep(TUNE.batchGapMs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ok = <T,>(r?: McRes): T | undefined =>
|
||||||
|
r && r.status === 'success' ? (r.result as T) : undefined
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// On-chain state sweeps + USD pricing.
|
||||||
|
//
|
||||||
|
// State per pool (multicall):
|
||||||
|
// univ3: slot0 + liquidity + erc20 balanceOf(token0/1) — balances (not L)
|
||||||
|
// are the TVL basis, matching how GT/dexscreener report "reserve".
|
||||||
|
// univ2: getReserves + totalSupply.
|
||||||
|
//
|
||||||
|
// Pricing is a waterfall: GeckoTerminal token prices are ground truth while
|
||||||
|
// fresh (stats.ts seeds them, depth = pool reserve/2); everything else comes
|
||||||
|
// from anchor propagation — a token gets priced through the deepest pool that
|
||||||
|
// pairs it against an already-priced token, requiring ≥ TUNE.minDepthUsd of
|
||||||
|
// priced-side depth so dust pools can't set prices. TVL then = sum of priced
|
||||||
|
// sides (single-priced-side pools: 2× that side, flagged approximate).
|
||||||
|
import { erc20Abi, formatUnits } from 'viem'
|
||||||
|
import { uniV2PairAbi, uniV3PoolAbi } from '../src/abi'
|
||||||
|
import { ADDR, TUNE, log, now } from './config'
|
||||||
|
import { mc, ok, type Call } from './rpc'
|
||||||
|
import {
|
||||||
|
allTokens,
|
||||||
|
db,
|
||||||
|
missingMetaTokens,
|
||||||
|
setTokenPrice,
|
||||||
|
setTvl,
|
||||||
|
tx,
|
||||||
|
upsertState,
|
||||||
|
upsertTokenMeta,
|
||||||
|
type PoolRow,
|
||||||
|
} from './store'
|
||||||
|
|
||||||
|
const printable = (s: unknown): string | null => {
|
||||||
|
if (typeof s !== 'string') return null
|
||||||
|
const t = s.replace(/[^\x20-\x7e]/g, '').trim()
|
||||||
|
return t ? t.slice(0, 24) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fetch symbol/decimals for catalog tokens we haven't met yet (10k/slice) */
|
||||||
|
export async function ensureTokenMeta(): Promise<number> {
|
||||||
|
const all = missingMetaTokens()
|
||||||
|
for (let i = 0; i < all.length; i += 10_000) {
|
||||||
|
const missing = all.slice(i, i + 10_000)
|
||||||
|
const res = await mc(
|
||||||
|
missing.flatMap((t) => [
|
||||||
|
{ abi: erc20Abi, address: t as `0x${string}`, functionName: 'symbol' },
|
||||||
|
{ abi: erc20Abi, address: t as `0x${string}`, functionName: 'decimals' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
tx(() => {
|
||||||
|
missing.forEach((t, j) => {
|
||||||
|
const sym = printable(ok<string>(res[j * 2]))
|
||||||
|
const dec = ok<number>(res[j * 2 + 1])
|
||||||
|
upsertTokenMeta(t, sym ?? t.slice(0, 6) + '…', dec ?? 18, sym !== null && dec !== undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return all.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolRowsQ = (addrs: string[]): PoolRow[] => {
|
||||||
|
const out: PoolRow[] = []
|
||||||
|
const q = db.prepare('SELECT address, proto, token0, token1, fee_ppm, tick_spacing FROM pools WHERE address = ?')
|
||||||
|
for (const a of addrs) {
|
||||||
|
const r = q.get(a.toLowerCase()) as PoolRow | undefined
|
||||||
|
if (r) out.push(r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** refresh raw on-chain state for the given pools (memory-bounded slices) */
|
||||||
|
export async function sweepState(addrs: string[]): Promise<number> {
|
||||||
|
let done = 0
|
||||||
|
for (let i = 0; i < addrs.length; i += 5_000) {
|
||||||
|
done += await sweepSlice(addrs.slice(i, i + 5_000))
|
||||||
|
}
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweepSlice(addrs: string[]): Promise<number> {
|
||||||
|
if (!addrs.length) return 0
|
||||||
|
const rows = poolRowsQ(addrs)
|
||||||
|
const calls: Call[] = []
|
||||||
|
for (const p of rows) {
|
||||||
|
const a = p.address as `0x${string}`
|
||||||
|
if (p.proto === 'univ3')
|
||||||
|
calls.push(
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'slot0' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'liquidity' },
|
||||||
|
{ abi: erc20Abi, address: p.token0 as `0x${string}`, functionName: 'balanceOf', args: [a] },
|
||||||
|
{ abi: erc20Abi, address: p.token1 as `0x${string}`, functionName: 'balanceOf', args: [a] },
|
||||||
|
)
|
||||||
|
else
|
||||||
|
calls.push(
|
||||||
|
{ abi: uniV2PairAbi, address: a, functionName: 'getReserves' },
|
||||||
|
{ abi: uniV2PairAbi, address: a, functionName: 'totalSupply' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const res = await mc(calls)
|
||||||
|
let i = 0
|
||||||
|
tx(() => {
|
||||||
|
for (const p of rows) {
|
||||||
|
if (p.proto === 'univ3') {
|
||||||
|
const s0 = ok<readonly [bigint, number, ...unknown[]]>(res[i++])
|
||||||
|
const liq = ok<bigint>(res[i++])
|
||||||
|
const b0 = ok<bigint>(res[i++])
|
||||||
|
const b1 = ok<bigint>(res[i++])
|
||||||
|
if (!s0) continue
|
||||||
|
upsertState(p.address, {
|
||||||
|
sqrtPrice: s0[0],
|
||||||
|
tick: s0[1],
|
||||||
|
liquidity: liq ?? 0n,
|
||||||
|
reserve0: b0 ?? 0n,
|
||||||
|
reserve1: b1 ?? 0n,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const rs = ok<readonly [bigint, bigint, number]>(res[i++])
|
||||||
|
const ts = ok<bigint>(res[i++])
|
||||||
|
if (!rs) continue
|
||||||
|
upsertState(p.address, { reserve0: rs[0], reserve1: rs[1], totalSupply: ts ?? 0n })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return rows.length
|
||||||
|
}
|
||||||
|
|
||||||
|
type PriceEntry = { usd: number; depth: number; src: string; updated: number }
|
||||||
|
|
||||||
|
const loadPrices = (): Map<string, PriceEntry> => {
|
||||||
|
const m = new Map<string, PriceEntry>()
|
||||||
|
for (const t of allTokens())
|
||||||
|
if (t.price_usd != null && t.price_usd > 0)
|
||||||
|
m.set(t.address, { usd: t.price_usd, depth: t.price_depth_usd, src: t.price_src ?? '?', updated: t.price_updated ?? 0 })
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateRow = {
|
||||||
|
address: string
|
||||||
|
proto: string
|
||||||
|
token0: string
|
||||||
|
token1: string
|
||||||
|
reserve0: string
|
||||||
|
reserve1: string
|
||||||
|
}
|
||||||
|
const statesQ = () =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.address, p.proto, p.token0, p.token1, s.reserve0, s.reserve1
|
||||||
|
FROM pools p JOIN pool_state s ON s.address = p.address`,
|
||||||
|
)
|
||||||
|
.all() as StateRow[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full pricing pass: propagate USD prices from GT/anchor seeds through pools,
|
||||||
|
* then recompute every pool's TVL. Pure JS over in-memory rows (~35k pools),
|
||||||
|
* runs after full sweeps and after each GT cycle.
|
||||||
|
*/
|
||||||
|
export function reprice(): { priced: number; tvlPools: number } {
|
||||||
|
const decs = new Map(allTokens().map((t) => [t.address, t.decimals]))
|
||||||
|
const prices = loadPrices()
|
||||||
|
// bootstrap anchor before the first GT cycle: USDG ≈ $1 (GT overwrites it)
|
||||||
|
if (!prices.has(ADDR.USDG.toLowerCase()))
|
||||||
|
prices.set(ADDR.USDG.toLowerCase(), { usd: 1, depth: 1, src: 'anchor', updated: now() })
|
||||||
|
|
||||||
|
const states = statesQ()
|
||||||
|
const human = (raw: string, addr: string) => Number(formatUnits(BigInt(raw), decs.get(addr) ?? 18))
|
||||||
|
const gtFresh = (e: PriceEntry) => e.src === 'gt' && now() - e.updated < TUNE.gtFreshSecs
|
||||||
|
|
||||||
|
const dirty = new Map<string, PriceEntry>()
|
||||||
|
for (let round = 0; round < 3; round++) {
|
||||||
|
let changed = 0
|
||||||
|
for (const s of states) {
|
||||||
|
const b0 = human(s.reserve0, s.token0)
|
||||||
|
const b1 = human(s.reserve1, s.token1)
|
||||||
|
for (const [known, other, kb, ob] of [
|
||||||
|
[s.token0, s.token1, b0, b1],
|
||||||
|
[s.token1, s.token0, b1, b0],
|
||||||
|
] as const) {
|
||||||
|
const kp = prices.get(known)
|
||||||
|
if (!kp || ob <= 0) continue
|
||||||
|
const depth = kb * kp.usd
|
||||||
|
if (depth < TUNE.minDepthUsd) continue
|
||||||
|
const existing = prices.get(other)
|
||||||
|
if (existing && (gtFresh(existing) || existing.depth >= depth)) continue
|
||||||
|
const e: PriceEntry = { usd: depth / ob, depth, src: 'pool', updated: now() }
|
||||||
|
prices.set(other, e)
|
||||||
|
dirty.set(other, e)
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break
|
||||||
|
}
|
||||||
|
|
||||||
|
let tvlPools = 0
|
||||||
|
tx(() => {
|
||||||
|
for (const [addr, e] of dirty) setTokenPrice(addr, e.usd, e.depth, e.src)
|
||||||
|
for (const s of states) {
|
||||||
|
const p0 = prices.get(s.token0)
|
||||||
|
const p1 = prices.get(s.token1)
|
||||||
|
const u0 = p0 ? human(s.reserve0, s.token0) * p0.usd : null
|
||||||
|
const u1 = p1 ? human(s.reserve1, s.token1) * p1.usd : null
|
||||||
|
const tvl = u0 != null && u1 != null ? u0 + u1 : u0 != null ? u0 * 2 : u1 != null ? u1 * 2 : null
|
||||||
|
setTvl(s.address, tvl, tvl != null && (u0 == null || u1 == null))
|
||||||
|
if (tvl != null) tvlPools++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { priced: prices.size, tvlPools }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cheap TVL refresh for a few pools using already-stored prices (no propagation) */
|
||||||
|
export function computeTvlFor(addrs: string[]): void {
|
||||||
|
if (!addrs.length) return
|
||||||
|
const decs = new Map(allTokens().map((t) => [t.address, t.decimals]))
|
||||||
|
const prices = loadPrices()
|
||||||
|
const q = db.prepare(
|
||||||
|
`SELECT p.address, p.proto, p.token0, p.token1, s.reserve0, s.reserve1
|
||||||
|
FROM pools p JOIN pool_state s ON s.address = p.address WHERE p.address = ?`,
|
||||||
|
)
|
||||||
|
tx(() => {
|
||||||
|
for (const a of addrs) {
|
||||||
|
const s = q.get(a.toLowerCase()) as StateRow | undefined
|
||||||
|
if (!s) continue
|
||||||
|
const human = (raw: string, addr: string) => Number(formatUnits(BigInt(raw), decs.get(addr) ?? 18))
|
||||||
|
const p0 = prices.get(s.token0)
|
||||||
|
const p1 = prices.get(s.token1)
|
||||||
|
const u0 = p0 ? human(s.reserve0, s.token0) * p0.usd : null
|
||||||
|
const u1 = p1 ? human(s.reserve1, s.token1) * p1.usd : null
|
||||||
|
const tvl = u0 != null && u1 != null ? u0 + u1 : u0 != null ? u0 * 2 : u1 != null ? u1 * 2 : null
|
||||||
|
setTvl(s.address, tvl, tvl != null && (u0 == null || u1 == null))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sweepLog = (label: string, n: number, ms: number) =>
|
||||||
|
log(`[sweep] ${label} ${n} pools in ${(ms / 1000).toFixed(1)}s`)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// GeckoTerminal enrichment — volume/liquidity/txn stats + token USD price
|
||||||
|
// seeds for the pricing waterfall. GT fully covers this chain's Uniswap
|
||||||
|
// deployments (network `robinhood`, per-dex top lists) but each list is capped
|
||||||
|
// at 10 pages × 20 = top 200 — the long tail keeps chain-derived TVL only.
|
||||||
|
// Free tier is 30 calls/min: calls are paced ≥ TUNE.gtPaceMs apart and the
|
||||||
|
// whole cycle (≤30 calls) runs every TUNE.statsMs.
|
||||||
|
//
|
||||||
|
// NOTE: GT has no UP33 dex entry — UP33 pool stats stay on the frontend's
|
||||||
|
// existing dexscreener path; this indexer only serves the Uniswap catalog.
|
||||||
|
import { GT, TUNE, log, sleep } from './config'
|
||||||
|
import { poolRow, setTokenPrice, upsertStats } from './store'
|
||||||
|
|
||||||
|
const LISTS = [
|
||||||
|
{ path: '/networks/robinhood/pools', label: 'network' },
|
||||||
|
{ path: '/networks/robinhood/dexes/uniswap-v2-robinhood/pools', label: 'uni-v2' },
|
||||||
|
{ path: '/networks/robinhood/dexes/uniswap-v3-robinhood/pools', label: 'uni-v3' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type GtPool = {
|
||||||
|
attributes?: {
|
||||||
|
address?: string
|
||||||
|
reserve_in_usd?: string
|
||||||
|
volume_usd?: { h24?: string }
|
||||||
|
transactions?: { h24?: { buys?: number; sells?: number } }
|
||||||
|
base_token_price_usd?: string
|
||||||
|
quote_token_price_usd?: string
|
||||||
|
}
|
||||||
|
relationships?: {
|
||||||
|
base_token?: { data?: { id?: string } }
|
||||||
|
quote_token?: { data?: { id?: string } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastCall = 0
|
||||||
|
async function gtJson(url: string): Promise<{ data?: GtPool[] } | null> {
|
||||||
|
const wait = lastCall + TUNE.gtPaceMs - Date.now()
|
||||||
|
if (wait > 0) await sleep(wait)
|
||||||
|
lastCall = Date.now()
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'up33-lp-indexer/0.1' } })
|
||||||
|
if (!r.ok) return null
|
||||||
|
return (await r.json()) as { data?: GtPool[] }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (x: unknown): number | null => {
|
||||||
|
const n = Number(x)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
const tokenOfId = (id?: string): string | null =>
|
||||||
|
id?.startsWith('robinhood_0x') ? id.slice('robinhood_'.length).toLowerCase() : null
|
||||||
|
|
||||||
|
function ingest(p: GtPool): boolean {
|
||||||
|
const a = p.attributes
|
||||||
|
const addr = a?.address?.toLowerCase()
|
||||||
|
if (!a || !addr || !poolRow(addr)) return false // catalog is the gate — unknown pools are ignored
|
||||||
|
const reserve = num(a.reserve_in_usd)
|
||||||
|
const h24 = a.transactions?.h24
|
||||||
|
const txns = h24 ? (h24.buys ?? 0) + (h24.sells ?? 0) : null
|
||||||
|
upsertStats(addr, num(a.volume_usd?.h24), txns, reserve, 'geckoterminal')
|
||||||
|
// token price seeds: ground truth while fresh; depth = half the pool's reserve
|
||||||
|
const depth = (reserve ?? 0) / 2
|
||||||
|
if (depth > 0) {
|
||||||
|
const base = tokenOfId(p.relationships?.base_token?.data?.id)
|
||||||
|
const quote = tokenOfId(p.relationships?.quote_token?.data?.id)
|
||||||
|
const bp = num(a.base_token_price_usd)
|
||||||
|
const qp = num(a.quote_token_price_usd)
|
||||||
|
if (base && bp && bp > 0) setTokenPrice(base, bp, depth, 'gt')
|
||||||
|
if (quote && qp && qp > 0) setTokenPrice(quote, qp, depth, 'gt')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** one enrichment cycle over the three GT top lists */
|
||||||
|
export async function gtCycle(): Promise<void> {
|
||||||
|
let matched = 0
|
||||||
|
let seen = 0
|
||||||
|
for (const list of LISTS) {
|
||||||
|
for (let page = 1; page <= 10; page++) {
|
||||||
|
const j = await gtJson(`${GT}${list.path}?page=${page}`)
|
||||||
|
const items = j?.data
|
||||||
|
if (!items?.length) break
|
||||||
|
seen += items.length
|
||||||
|
for (const it of items) if (ingest(it)) matched++
|
||||||
|
if (items.length < 20) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(`[stats] gt cycle: ${matched}/${seen} list entries matched catalog`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// SQLite store (node:sqlite — built into node ≥22.13, zero dependencies).
|
||||||
|
// bigints are stored as TEXT and travel as strings through the API; REAL
|
||||||
|
// columns are display/ranking data only, never used to build transactions.
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { DB_PATH, now } from './config'
|
||||||
|
|
||||||
|
mkdirSync(dirname(DB_PATH), { recursive: true })
|
||||||
|
export const db = new DatabaseSync(DB_PATH)
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pools (
|
||||||
|
address TEXT PRIMARY KEY, -- lowercase
|
||||||
|
proto TEXT NOT NULL, -- 'univ2' | 'univ3'
|
||||||
|
token0 TEXT NOT NULL, -- lowercase
|
||||||
|
token1 TEXT NOT NULL,
|
||||||
|
fee_ppm INTEGER NOT NULL, -- univ2 fixed 3000 (0.30%)
|
||||||
|
tick_spacing INTEGER, -- univ3 only
|
||||||
|
created_block INTEGER, -- univ3 only (from PoolCreated)
|
||||||
|
pair_index INTEGER, -- univ2 only (allPairs index)
|
||||||
|
added_ts INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pools_t0 ON pools(token0);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pools_t1 ON pools(token1);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tokens (
|
||||||
|
address TEXT PRIMARY KEY,
|
||||||
|
symbol TEXT NOT NULL DEFAULT '?',
|
||||||
|
decimals INTEGER NOT NULL DEFAULT 18,
|
||||||
|
meta_ok INTEGER NOT NULL DEFAULT 0, -- 0 = symbol/decimals defaulted (call reverted)
|
||||||
|
price_usd REAL,
|
||||||
|
price_depth_usd REAL NOT NULL DEFAULT 0, -- USD depth backing the price (bigger wins)
|
||||||
|
price_src TEXT, -- 'gt' | 'pool' | 'anchor'
|
||||||
|
price_updated INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_state (
|
||||||
|
address TEXT PRIMARY KEY,
|
||||||
|
sqrt_price TEXT, -- univ3
|
||||||
|
tick INTEGER, -- univ3
|
||||||
|
liquidity TEXT, -- univ3 in-range L
|
||||||
|
reserve0 TEXT NOT NULL DEFAULT '0', -- univ2: reserves; univ3: erc20 balances (TVL basis)
|
||||||
|
reserve1 TEXT NOT NULL DEFAULT '0',
|
||||||
|
total_supply TEXT, -- univ2 LP supply
|
||||||
|
tvl_usd REAL,
|
||||||
|
tvl_approx INTEGER NOT NULL DEFAULT 0, -- 1 = only one side priced (tvl = 2× that side)
|
||||||
|
updated INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_state_tvl ON pool_state(tvl_usd);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_stats (
|
||||||
|
address TEXT PRIMARY KEY,
|
||||||
|
vol24h_usd REAL,
|
||||||
|
txns24h INTEGER,
|
||||||
|
liq_usd REAL, -- GT's own reserve figure (cross-check; tvl_usd is chain-derived)
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
updated INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// ---- kv ----
|
||||||
|
const kvGetQ = db.prepare('SELECT v FROM kv WHERE k = ?')
|
||||||
|
const kvSetQ = db.prepare('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')
|
||||||
|
export const kvGet = (k: string): string | undefined => (kvGetQ.get(k) as { v: string } | undefined)?.v
|
||||||
|
export const kvSet = (k: string, v: string) => void kvSetQ.run(k, v)
|
||||||
|
|
||||||
|
// ---- pools ----
|
||||||
|
const insPoolQ = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO pools (address, proto, token0, token1, fee_ppm, tick_spacing, created_block, pair_index, added_ts)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
/** returns true when the pool is new */
|
||||||
|
export function insertPool(p: {
|
||||||
|
address: string
|
||||||
|
proto: 'univ2' | 'univ3'
|
||||||
|
token0: string
|
||||||
|
token1: string
|
||||||
|
feePpm: number
|
||||||
|
tickSpacing?: number
|
||||||
|
createdBlock?: number
|
||||||
|
pairIndex?: number
|
||||||
|
}): boolean {
|
||||||
|
const r = insPoolQ.run(
|
||||||
|
p.address.toLowerCase(),
|
||||||
|
p.proto,
|
||||||
|
p.token0.toLowerCase(),
|
||||||
|
p.token1.toLowerCase(),
|
||||||
|
p.feePpm,
|
||||||
|
p.tickSpacing ?? null,
|
||||||
|
p.createdBlock ?? null,
|
||||||
|
p.pairIndex ?? null,
|
||||||
|
now(),
|
||||||
|
)
|
||||||
|
return Number(r.changes) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PoolRow = {
|
||||||
|
address: string
|
||||||
|
proto: 'univ2' | 'univ3'
|
||||||
|
token0: string
|
||||||
|
token1: string
|
||||||
|
fee_ppm: number
|
||||||
|
tick_spacing: number | null
|
||||||
|
}
|
||||||
|
const poolsByAddrQ = db.prepare('SELECT address, proto, token0, token1, fee_ppm, tick_spacing FROM pools WHERE address = ?')
|
||||||
|
export const poolRow = (addr: string) => poolsByAddrQ.get(addr.toLowerCase()) as PoolRow | undefined
|
||||||
|
export const allPoolAddrs = (): string[] =>
|
||||||
|
(db.prepare('SELECT address FROM pools').all() as { address: string }[]).map((r) => r.address)
|
||||||
|
export const poolCounts = () =>
|
||||||
|
db.prepare(`SELECT proto, COUNT(*) AS n FROM pools GROUP BY proto`).all() as { proto: string; n: number }[]
|
||||||
|
|
||||||
|
// ---- tokens ----
|
||||||
|
const insTokenQ = db.prepare(`
|
||||||
|
INSERT INTO tokens (address, symbol, decimals, meta_ok) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(address) DO UPDATE SET symbol = excluded.symbol, decimals = excluded.decimals, meta_ok = excluded.meta_ok`)
|
||||||
|
export const upsertTokenMeta = (addr: string, symbol: string, decimals: number, metaOk: boolean) =>
|
||||||
|
void insTokenQ.run(addr.toLowerCase(), symbol, decimals, metaOk ? 1 : 0)
|
||||||
|
|
||||||
|
const priceQ = db.prepare(`
|
||||||
|
INSERT INTO tokens (address, price_usd, price_depth_usd, price_src, price_updated) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(address) DO UPDATE SET price_usd = excluded.price_usd, price_depth_usd = excluded.price_depth_usd,
|
||||||
|
price_src = excluded.price_src, price_updated = excluded.price_updated`)
|
||||||
|
export const setTokenPrice = (addr: string, usd: number, depthUsd: number, src: string) =>
|
||||||
|
void priceQ.run(addr.toLowerCase(), usd, depthUsd, src, now())
|
||||||
|
|
||||||
|
export type TokenRow = {
|
||||||
|
address: string
|
||||||
|
symbol: string
|
||||||
|
decimals: number
|
||||||
|
meta_ok: number
|
||||||
|
price_usd: number | null
|
||||||
|
price_depth_usd: number
|
||||||
|
price_src: string | null
|
||||||
|
price_updated: number | null
|
||||||
|
}
|
||||||
|
export const allTokens = () => db.prepare('SELECT * FROM tokens').all() as TokenRow[]
|
||||||
|
export const missingMetaTokens = (): string[] =>
|
||||||
|
(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT u.addr FROM (SELECT token0 AS addr FROM pools UNION SELECT token1 FROM pools) u
|
||||||
|
LEFT JOIN tokens t ON t.address = u.addr WHERE t.address IS NULL`,
|
||||||
|
)
|
||||||
|
.all() as { addr: string }[]
|
||||||
|
).map((r) => r.addr)
|
||||||
|
|
||||||
|
// ---- pool_state ----
|
||||||
|
const upStateQ = db.prepare(`
|
||||||
|
INSERT INTO pool_state (address, sqrt_price, tick, liquidity, reserve0, reserve1, total_supply, updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(address) DO UPDATE SET sqrt_price = excluded.sqrt_price, tick = excluded.tick,
|
||||||
|
liquidity = excluded.liquidity, reserve0 = excluded.reserve0, reserve1 = excluded.reserve1,
|
||||||
|
total_supply = excluded.total_supply, updated = excluded.updated`)
|
||||||
|
export const upsertState = (
|
||||||
|
addr: string,
|
||||||
|
s: { sqrtPrice?: bigint; tick?: number; liquidity?: bigint; reserve0: bigint; reserve1: bigint; totalSupply?: bigint },
|
||||||
|
) =>
|
||||||
|
void upStateQ.run(
|
||||||
|
addr.toLowerCase(),
|
||||||
|
s.sqrtPrice !== undefined ? String(s.sqrtPrice) : null,
|
||||||
|
s.tick ?? null,
|
||||||
|
s.liquidity !== undefined ? String(s.liquidity) : null,
|
||||||
|
String(s.reserve0),
|
||||||
|
String(s.reserve1),
|
||||||
|
s.totalSupply !== undefined ? String(s.totalSupply) : null,
|
||||||
|
now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tvlQ = db.prepare('UPDATE pool_state SET tvl_usd = ?, tvl_approx = ? WHERE address = ?')
|
||||||
|
export const setTvl = (addr: string, tvl: number | null, approx: boolean) =>
|
||||||
|
void tvlQ.run(tvl, approx ? 1 : 0, addr.toLowerCase())
|
||||||
|
|
||||||
|
// ---- pool_stats ----
|
||||||
|
const upStatsQ = db.prepare(`
|
||||||
|
INSERT INTO pool_stats (address, vol24h_usd, txns24h, liq_usd, source, updated) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(address) DO UPDATE SET vol24h_usd = excluded.vol24h_usd, txns24h = excluded.txns24h,
|
||||||
|
liq_usd = excluded.liq_usd, source = excluded.source, updated = excluded.updated`)
|
||||||
|
export const upsertStats = (addr: string, vol24h: number | null, txns24h: number | null, liqUsd: number | null, source: string) =>
|
||||||
|
void upStatsQ.run(addr.toLowerCase(), vol24h, txns24h, liqUsd, source, now())
|
||||||
|
|
||||||
|
/** hot set: real TVL, or GT-visible activity, or freshly created */
|
||||||
|
export const hotAddrs = (): string[] =>
|
||||||
|
(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT address FROM pool_state WHERE tvl_usd >= ?
|
||||||
|
UNION SELECT address FROM pool_stats WHERE vol24h_usd > 0
|
||||||
|
UNION SELECT address FROM pools WHERE added_ts > ?`,
|
||||||
|
)
|
||||||
|
.all(10_000, now() - 3_600) as { address: string }[]
|
||||||
|
).map((r) => r.address)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* active set for the hourly sweep: anything that ever showed ≥$100 TVL plus
|
||||||
|
* everything younger than 48h. The launchpads mint ~20k dust pools/day — the
|
||||||
|
* 6-hourly census (allPoolAddrs) keeps their state honest, the hourly sweep
|
||||||
|
* stays bounded by real liquidity instead of catalog size.
|
||||||
|
*/
|
||||||
|
export const activeAddrs = (): string[] =>
|
||||||
|
(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT address FROM pool_state WHERE tvl_usd >= ?
|
||||||
|
UNION SELECT address FROM pools WHERE added_ts > ?`,
|
||||||
|
)
|
||||||
|
.all(100, now() - 172_800) as { address: string }[]
|
||||||
|
).map((r) => r.address)
|
||||||
|
|
||||||
|
export const tx = (fn: () => void) => {
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
fn()
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "lp-terminal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"smoke": "tsx scripts/smoke.ts",
|
||||||
|
"smoke:uni": "tsx scripts/uni-browse-smoke.ts",
|
||||||
|
"indexer": "tsx indexer/main.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rainbow-me/rainbowkit": "2.2.11",
|
||||||
|
"@tanstack/react-query": "5.101.2",
|
||||||
|
"i18next": "26.3.6",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-i18next": "17.0.10",
|
||||||
|
"viem": "2.55.2",
|
||||||
|
"wagmi": "2.19.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.20.1",
|
||||||
|
"@types/react": "18.3.31",
|
||||||
|
"@types/react-dom": "18.3.7",
|
||||||
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
|
"tsx": "4.23.1",
|
||||||
|
"typescript": "5.6.3",
|
||||||
|
"vite": "6.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Live-chain smoke test for the read layer: TickMath constants, ABI selectors,
|
||||||
|
// pool discovery, quoter-vs-kyber sanity. Run: npm run smoke
|
||||||
|
// Never prints the RPC URL.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { createPublicClient, defineChain, http, parseUnits, type Address } from 'viem'
|
||||||
|
import {
|
||||||
|
clFactoryAbi,
|
||||||
|
clGaugeAbi,
|
||||||
|
clPoolAbi,
|
||||||
|
quoterAbi,
|
||||||
|
v2FactoryAbi,
|
||||||
|
v2GaugeAbi,
|
||||||
|
voterAbi,
|
||||||
|
} from '../src/abi'
|
||||||
|
import { ADDR } from '../src/config/addresses'
|
||||||
|
import {
|
||||||
|
getAmountsForLiquidity,
|
||||||
|
getLiquidityForAmounts,
|
||||||
|
getSqrtRatioAtTick,
|
||||||
|
sqrtPriceToPrice,
|
||||||
|
} from '../src/lib/clmath'
|
||||||
|
|
||||||
|
let pass = 0
|
||||||
|
let fail = 0
|
||||||
|
function check(name: string, cond: boolean, detail = '') {
|
||||||
|
if (cond) pass++
|
||||||
|
else fail++
|
||||||
|
console.log(` ${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// duplicated rather than imported: src/config/env.ts reads import.meta.env,
|
||||||
|
// which is vite-only and does not load under node/tsx.
|
||||||
|
const PUBLIC_RPC = 'https://rpc.mainnet.chain.robinhood.com'
|
||||||
|
|
||||||
|
/** repo-root .env `RPC` (SECRET — never print it). No .env / no key: public RPC. */
|
||||||
|
const rpc = (() => {
|
||||||
|
const fromEnv = process.env.RPC?.trim()
|
||||||
|
if (fromEnv) return fromEnv
|
||||||
|
try {
|
||||||
|
const text = readFileSync(fileURLToPath(new URL('../.env', import.meta.url)), 'utf8')
|
||||||
|
return text.match(/^\s*RPC\s*=\s*(\S+)\s*$/m)?.[1] ?? PUBLIC_RPC
|
||||||
|
} catch {
|
||||||
|
return PUBLIC_RPC
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const chain = defineChain({
|
||||||
|
id: 4663,
|
||||||
|
name: 'Robinhood Chain',
|
||||||
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||||
|
rpcUrls: { default: { http: [rpc] } },
|
||||||
|
contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
|
||||||
|
})
|
||||||
|
const pc = createPublicClient({ chain, transport: http(rpc, { batch: true }) })
|
||||||
|
|
||||||
|
type Res = { status: 'success' | 'failure'; result?: unknown }
|
||||||
|
const ok = <T,>(r: Res | undefined): T | undefined =>
|
||||||
|
r && r.status === 'success' ? (r.result as T) : undefined
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('== UP33 TERMINAL smoke test ==')
|
||||||
|
|
||||||
|
// 1. chain id
|
||||||
|
const id = await pc.getChainId()
|
||||||
|
check('chainId == 4663', id === 4663, String(id))
|
||||||
|
|
||||||
|
// 2. TickMath constants vs float reference
|
||||||
|
let maxRel = 0
|
||||||
|
for (const t of [-887200, -200000, -46055, -100, -1, 0, 1, 100, 46054, 200000, 443636, 887200]) {
|
||||||
|
const exact = Number(getSqrtRatioAtTick(t)) / 2 ** 96
|
||||||
|
const approx = Math.sqrt(Math.pow(1.0001, t))
|
||||||
|
const rel = Math.abs(exact - approx) / approx
|
||||||
|
if (rel > maxRel) maxRel = rel
|
||||||
|
}
|
||||||
|
check('TickMath matches float ref (<1e-9 rel)', maxRel < 1e-9, `maxRel=${maxRel.toExponential(2)}`)
|
||||||
|
|
||||||
|
// 3. liquidity math round-trip
|
||||||
|
{
|
||||||
|
const sqrtP = getSqrtRatioAtTick(102000)
|
||||||
|
const sqrtA = getSqrtRatioAtTick(100000)
|
||||||
|
const sqrtB = getSqrtRatioAtTick(104000)
|
||||||
|
const in0 = parseUnits('1', 18)
|
||||||
|
const L = getLiquidityForAmounts(sqrtP, sqrtA, sqrtB, in0, (1n << 255n))
|
||||||
|
const { amount0 } = getAmountsForLiquidity(sqrtP, sqrtA, sqrtB, L)
|
||||||
|
const rel = Number(((in0 - amount0) * 1_000_000n) / in0) / 1e6
|
||||||
|
check('liquidity round-trip (amount0 within 0.01%)', amount0 <= in0 && rel < 1e-4, `loss=${rel}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. factories enumerable
|
||||||
|
const head = (await pc.multicall({
|
||||||
|
contracts: [
|
||||||
|
{ abi: v2FactoryAbi, address: ADDR.V2_FACTORY, functionName: 'allPoolsLength' },
|
||||||
|
{ abi: clFactoryAbi, address: ADDR.CL_FACTORY, functionName: 'allPoolsLength' },
|
||||||
|
] as never,
|
||||||
|
})) as Res[]
|
||||||
|
const v2Len = Number(ok<bigint>(head[0]) ?? 0n)
|
||||||
|
const clLen = Number(ok<bigint>(head[1]) ?? 0n)
|
||||||
|
check('factories enumerable', v2Len >= 3 && clLen >= 15, `${v2Len} v2 + ${clLen} CL pools`)
|
||||||
|
|
||||||
|
// 5. enumerate CL pools, validate slot0 tick <-> sqrtPrice against our TickMath
|
||||||
|
const clAddrRes = (await pc.multicall({
|
||||||
|
contracts: Array.from({ length: clLen }, (_, i) => ({
|
||||||
|
abi: clFactoryAbi,
|
||||||
|
address: ADDR.CL_FACTORY,
|
||||||
|
functionName: 'allPools',
|
||||||
|
args: [BigInt(i)],
|
||||||
|
})) as never,
|
||||||
|
})) as Res[]
|
||||||
|
const clAddrs = clAddrRes.map((r) => ok<Address>(r)).filter(Boolean) as Address[]
|
||||||
|
const slotRes = (await pc.multicall({
|
||||||
|
contracts: clAddrs.flatMap((p) => [
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'slot0' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'token0' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'token1' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'tickSpacing' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'liquidity' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'gauge' },
|
||||||
|
]) as never,
|
||||||
|
})) as Res[]
|
||||||
|
let tickOk = 0
|
||||||
|
let tickBad = 0
|
||||||
|
const clPools: {
|
||||||
|
addr: Address
|
||||||
|
sqrtP: bigint
|
||||||
|
tick: number
|
||||||
|
t0: Address
|
||||||
|
t1: Address
|
||||||
|
ts: number
|
||||||
|
liq: bigint
|
||||||
|
gauge?: Address
|
||||||
|
}[] = []
|
||||||
|
clAddrs.forEach((p, i) => {
|
||||||
|
const s0 = ok<readonly [bigint, number, number, number, number, boolean]>(slotRes[i * 6])
|
||||||
|
const t0 = ok<Address>(slotRes[i * 6 + 1])
|
||||||
|
const t1 = ok<Address>(slotRes[i * 6 + 2])
|
||||||
|
const ts = ok<number>(slotRes[i * 6 + 3])
|
||||||
|
const liq = ok<bigint>(slotRes[i * 6 + 4]) ?? 0n
|
||||||
|
const gauge = ok<Address>(slotRes[i * 6 + 5])
|
||||||
|
if (!s0 || !t0 || !t1 || ts === undefined) return
|
||||||
|
clPools.push({ addr: p, sqrtP: s0[0], tick: s0[1], t0, t1, ts, liq, gauge })
|
||||||
|
if (s0[0] === 0n) return
|
||||||
|
const lo = getSqrtRatioAtTick(s0[1])
|
||||||
|
const hi = getSqrtRatioAtTick(s0[1] + 1)
|
||||||
|
if (lo <= s0[0] && s0[0] < hi) tickOk++
|
||||||
|
else tickBad++
|
||||||
|
})
|
||||||
|
check('slot0 sqrtPrice within [tick, tick+1) for all CL pools', tickBad === 0, `${tickOk} ok / ${tickBad} bad`)
|
||||||
|
|
||||||
|
// 6. WETH/UP CL pool: our quoter path vs kyber aggregator ballpark
|
||||||
|
const wethUp = clPools
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
(p.t0.toLowerCase() === ADDR.WETH.toLowerCase() && p.t1.toLowerCase() === ADDR.UP.toLowerCase()) ||
|
||||||
|
(p.t1.toLowerCase() === ADDR.WETH.toLowerCase() && p.t0.toLowerCase() === ADDR.UP.toLowerCase()),
|
||||||
|
)
|
||||||
|
.sort((a, b) => (b.liq > a.liq ? 1 : -1))[0]
|
||||||
|
check('WETH/UP CL pool exists', !!wethUp, wethUp?.addr ?? '')
|
||||||
|
let quoterOut: bigint | undefined
|
||||||
|
if (wethUp) {
|
||||||
|
const oneWeth = parseUnits('1', 18)
|
||||||
|
const qRes = (await pc.multicall({
|
||||||
|
contracts: [
|
||||||
|
{
|
||||||
|
abi: quoterAbi,
|
||||||
|
address: ADDR.CL_QUOTER,
|
||||||
|
functionName: 'quoteExactInputSingle',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
tokenIn: ADDR.WETH,
|
||||||
|
tokenOut: ADDR.UP,
|
||||||
|
amountIn: oneWeth,
|
||||||
|
tickSpacing: wethUp.ts,
|
||||||
|
sqrtPriceLimitX96: 0n,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as never,
|
||||||
|
})) as Res[]
|
||||||
|
const q = ok<readonly [bigint, bigint, number, bigint]>(qRes[0])
|
||||||
|
quoterOut = q?.[0]
|
||||||
|
check('quoter quotes via multicall (view-cast ABI works)', quoterOut !== undefined && quoterOut > 0n, `1 WETH -> ${quoterOut} UP wei`)
|
||||||
|
|
||||||
|
// spot price sanity vs quoter (small trade impact expected)
|
||||||
|
const spot = sqrtPriceToPrice(
|
||||||
|
wethUp.sqrtP,
|
||||||
|
wethUp.t0.toLowerCase() === ADDR.WETH.toLowerCase() ? 18 : 18,
|
||||||
|
18,
|
||||||
|
)
|
||||||
|
const upPerWeth = wethUp.t0.toLowerCase() === ADDR.WETH.toLowerCase() ? spot : 1 / spot
|
||||||
|
if (quoterOut) {
|
||||||
|
const outF = Number(quoterOut) / 1e18
|
||||||
|
const rel = Math.abs(outF - upPerWeth) / upPerWeth
|
||||||
|
check('quoter ~ spot price (<30% incl. fee+impact)', rel < 0.3, `spot=${upPerWeth.toFixed(1)} quote=${outF.toFixed(1)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`https://aggregator-api.kyberswap.com/robinhood/api/v1/routes?tokenIn=${ADDR.WETH}&tokenOut=${ADDR.UP}&amountIn=${oneWeth}`,
|
||||||
|
{ headers: { 'x-client-id': 'up33-terminal-smoke' } },
|
||||||
|
)
|
||||||
|
const j: any = await r.json()
|
||||||
|
const aggOut = BigInt(j?.data?.routeSummary?.amountOut ?? '0')
|
||||||
|
if (aggOut > 0n && quoterOut) {
|
||||||
|
const rel = Math.abs(Number(aggOut - quoterOut)) / Number(aggOut)
|
||||||
|
check('kyber agg vs our single-pool quote (<25%)', rel < 0.25, `agg=${aggOut} ours=${quoterOut}`)
|
||||||
|
} else {
|
||||||
|
check('kyber aggregator reachable', aggOut > 0n, j?.message ?? 'no data')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
check('kyber aggregator reachable', false, String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. v2 gauge standard selectors respond (gauge instances are unverified on Blockscout)
|
||||||
|
const v2AddrRes = (await pc.multicall({
|
||||||
|
contracts: Array.from({ length: v2Len }, (_, i) => ({
|
||||||
|
abi: v2FactoryAbi,
|
||||||
|
address: ADDR.V2_FACTORY,
|
||||||
|
functionName: 'allPools',
|
||||||
|
args: [BigInt(i)],
|
||||||
|
})) as never,
|
||||||
|
})) as Res[]
|
||||||
|
const v2Addrs = v2AddrRes.map((r) => ok<Address>(r)).filter(Boolean) as Address[]
|
||||||
|
const gaugeRes = (await pc.multicall({
|
||||||
|
contracts: v2Addrs.map((p) => ({
|
||||||
|
abi: voterAbi,
|
||||||
|
address: ADDR.VOTER,
|
||||||
|
functionName: 'gauges',
|
||||||
|
args: [p],
|
||||||
|
})) as never,
|
||||||
|
})) as Res[]
|
||||||
|
const zero = '0x0000000000000000000000000000000000000000'
|
||||||
|
const v2Gauge = gaugeRes.map((r) => ok<Address>(r)).find((g) => g && g !== zero)
|
||||||
|
if (v2Gauge) {
|
||||||
|
const rnd = '0x00000000000000000000000000000000000000ff' as Address
|
||||||
|
const gRes = (await pc.multicall({
|
||||||
|
contracts: [
|
||||||
|
{ abi: v2GaugeAbi, address: v2Gauge, functionName: 'earned', args: [rnd] },
|
||||||
|
{ abi: v2GaugeAbi, address: v2Gauge, functionName: 'balanceOf', args: [rnd] },
|
||||||
|
{ abi: v2GaugeAbi, address: v2Gauge, functionName: 'rewardRate' },
|
||||||
|
{ abi: v2GaugeAbi, address: v2Gauge, functionName: 'stakingToken' },
|
||||||
|
] as never,
|
||||||
|
})) as Res[]
|
||||||
|
check(
|
||||||
|
'v2 gauge standard selectors respond',
|
||||||
|
gRes.every((r) => r.status === 'success'),
|
||||||
|
v2Gauge,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
check('v2 gauge found', false, 'no gauged v2 pool')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. CL gauge stakedValues() responds
|
||||||
|
const clGauge = clPools.find((p) => p.gauge && p.gauge !== zero)?.gauge
|
||||||
|
if (clGauge) {
|
||||||
|
const sv = (await pc.multicall({
|
||||||
|
contracts: [
|
||||||
|
{
|
||||||
|
abi: clGaugeAbi,
|
||||||
|
address: clGauge,
|
||||||
|
functionName: 'stakedValues',
|
||||||
|
args: ['0x0eEA30aBa3f07abFA20E4b544F55e0f917d9DFd8'],
|
||||||
|
},
|
||||||
|
] as never,
|
||||||
|
})) as Res[]
|
||||||
|
check('CL gauge stakedValues responds', sv[0].status === 'success', clGauge)
|
||||||
|
} else {
|
||||||
|
check('CL gauge found', false, 'no gauged CL pool')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n== ${pass} passed, ${fail} failed ==`)
|
||||||
|
process.exit(fail === 0 ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
// avoid leaking the RPC URL inside error strings
|
||||||
|
console.error('smoke crashed:', String(e).replace(rpc, '<rpc>'))
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Live smoke for the univ3 POOLS browse layer: replicates fetchUniBrowse
|
||||||
|
// (src/lib/uniBrowse.ts) with the REAL abi/address/clmath modules — env.ts
|
||||||
|
// can't load under node, so the flow is mirrored, not imported.
|
||||||
|
// Never prints the RPC URL.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createPublicClient, defineChain, encodeFunctionData, getAddress, http, zeroAddress, type Address, type PublicClient } from 'viem'
|
||||||
|
import { uniV3FactoryAbi, uniV3PmAbi, uniV3PoolAbi } from '../src/abi/index'
|
||||||
|
import { ADDR, UNI } from '../src/config/addresses'
|
||||||
|
import { getLiquidityForAmounts, getSqrtRatioAtTick, minAmountsForLiquidity } from '../src/lib/clmath'
|
||||||
|
|
||||||
|
// duplicated rather than imported: src/config/env.ts reads import.meta.env,
|
||||||
|
// which is vite-only and does not load under node/tsx.
|
||||||
|
const PUBLIC_RPC = 'https://rpc.mainnet.chain.robinhood.com'
|
||||||
|
|
||||||
|
/** repo-root .env `RPC` (SECRET — never print it). No .env / no key: public RPC. */
|
||||||
|
const rpc = (() => {
|
||||||
|
const fromEnv = process.env.RPC?.trim()
|
||||||
|
if (fromEnv) return fromEnv
|
||||||
|
try {
|
||||||
|
const text = readFileSync(new URL('../.env', import.meta.url), 'utf8')
|
||||||
|
return text.match(/^\s*RPC\s*=\s*(\S+)\s*$/m)?.[1] ?? PUBLIC_RPC
|
||||||
|
} catch {
|
||||||
|
return PUBLIC_RPC
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const chain = defineChain({
|
||||||
|
id: 4663,
|
||||||
|
name: 'Robinhood Chain',
|
||||||
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||||
|
rpcUrls: { default: { http: [rpc] } },
|
||||||
|
contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
|
||||||
|
})
|
||||||
|
const pc = createPublicClient({ chain, transport: http(rpc, { batch: true }) }) as PublicClient
|
||||||
|
|
||||||
|
let fails = 0
|
||||||
|
function check(name: string, cond: boolean, detail = '') {
|
||||||
|
console.log(`${cond ? 'ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`)
|
||||||
|
if (!cond) fails++
|
||||||
|
}
|
||||||
|
|
||||||
|
type DsPair = { chainId?: string; dexId?: string; labels?: string[]; pairAddress?: string; volume?: { h24?: number }; liquidity?: { usd?: number } }
|
||||||
|
const v3PairsOf = (json: unknown): DsPair[] => {
|
||||||
|
const arr = Array.isArray(json) ? (json as DsPair[]) : ((json as { pairs?: DsPair[] })?.pairs ?? [])
|
||||||
|
return arr.filter((p) => p?.chainId === 'robinhood' && p?.dexId === 'uniswap' && (p?.labels ?? []).includes('v3'))
|
||||||
|
}
|
||||||
|
type McRes = { status: string; result?: unknown }
|
||||||
|
const ok = <T,>(r: McRes | undefined): T | undefined => (r && r.status === 'success' ? (r.result as T) : undefined)
|
||||||
|
|
||||||
|
const FEE_TS: Record<number, number> = { 100: 1, 500: 10, 3000: 60, 10000: 200 }
|
||||||
|
const KNOWN_POOL = '0xa9188730fe85be88ad499d7d52b099e800fb0334' // WETH/USDG 0.3% (verified earlier)
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. token-pairs discovery for WETH (the default browse query)
|
||||||
|
const tp = await (await fetch(`https://api.dexscreener.com/token-pairs/v1/robinhood/${ADDR.WETH}`)).json()
|
||||||
|
const cands = v3PairsOf(tp)
|
||||||
|
check('dexscreener token-pairs finds v3 WETH pools', cands.length >= 1, `${cands.length} candidates`)
|
||||||
|
// token-pairs caps at ~30 pairs/token (activity-ordered) — a pool missing from
|
||||||
|
// one token's list is reachable via its OTHER token; assert exactly that:
|
||||||
|
const tpU = await (await fetch(`https://api.dexscreener.com/token-pairs/v1/robinhood/${ADDR.USDG}`)).json()
|
||||||
|
check('known USDG/WETH 0.3% pool reachable via USDG query', v3PairsOf(tpU).some((p) => p.pairAddress?.toLowerCase() === KNOWN_POOL))
|
||||||
|
|
||||||
|
// 2. rank by TVL + cap (same as the lib)
|
||||||
|
const seen = new Map<string, DsPair>()
|
||||||
|
for (const p of cands) { const a = p.pairAddress?.toLowerCase(); if (a && !seen.has(a)) seen.set(a, p) }
|
||||||
|
const picks = [...seen.values()].sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0)).slice(0, 30)
|
||||||
|
console.log(` top pools by TVL: ${picks.slice(0, 5).map((p) => `${p.pairAddress?.slice(0, 8)}($${Math.round(p.liquidity?.usd ?? 0)})`).join(' ')}`)
|
||||||
|
|
||||||
|
// 3. hydrate from the pool contracts
|
||||||
|
const addrs = picks.map((p) => getAddress(p.pairAddress!))
|
||||||
|
const det = (await pc.multicall({
|
||||||
|
contracts: addrs.flatMap((a) => [
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'token0' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'token1' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'fee' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'tickSpacing' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'slot0' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'liquidity' },
|
||||||
|
]) as never,
|
||||||
|
})) as McRes[]
|
||||||
|
type Hyd = { addr: Address; token0: Address; token1: Address; fee: number; ts: number; sqrtP: bigint; tick: number; liq: bigint }
|
||||||
|
const hyd: Hyd[] = []
|
||||||
|
addrs.forEach((a, i) => {
|
||||||
|
const token0 = ok<Address>(det[i * 6]); const token1 = ok<Address>(det[i * 6 + 1])
|
||||||
|
const fee = ok<number>(det[i * 6 + 2]); const ts = ok<number>(det[i * 6 + 3])
|
||||||
|
const s0 = ok<readonly [bigint, number]>(det[i * 6 + 4]); const liq = ok<bigint>(det[i * 6 + 5])
|
||||||
|
if (!token0 || !token1 || fee === undefined || ts === undefined || !s0) return
|
||||||
|
hyd.push({ addr: a, token0, token1, fee, ts, sqrtP: s0[0], tick: s0[1], liq: liq ?? 0n })
|
||||||
|
})
|
||||||
|
check('all candidates hydrate on-chain', hyd.length === addrs.length, `${hyd.length}/${addrs.length}`)
|
||||||
|
check('fee↔tickSpacing mapping consistent', hyd.every((h) => FEE_TS[h.fee] === h.ts))
|
||||||
|
|
||||||
|
// 4. factory.getPool authenticity round-trip
|
||||||
|
const gp = (await pc.multicall({
|
||||||
|
contracts: hyd.map((h) => ({ abi: uniV3FactoryAbi, address: UNI.V3_FACTORY, functionName: 'getPool', args: [h.token0, h.token1, h.fee] })) as never,
|
||||||
|
})) as McRes[]
|
||||||
|
const verified = hyd.filter((h, i) => {
|
||||||
|
const m = ok<Address>(gp[i])
|
||||||
|
return !!m && m !== zeroAddress && m.toLowerCase() === h.addr.toLowerCase()
|
||||||
|
})
|
||||||
|
check('factory.getPool verifies every pool', verified.length === hyd.length, `${verified.length}/${hyd.length} (drops would be spoofs)`)
|
||||||
|
|
||||||
|
// 5. symbol search path
|
||||||
|
const sr = await (await fetch('https://api.dexscreener.com/latest/dex/search?q=USDG')).json()
|
||||||
|
check('symbol search returns robinhood v3 pairs', v3PairsOf(sr).length >= 1, `${v3PairsOf(sr).length} matches`)
|
||||||
|
|
||||||
|
// 6. mint calldata: canonical univ3 selector + slippage mins sane on live state
|
||||||
|
const h0 = verified.find((h) => h.addr.toLowerCase() === KNOWN_POOL) ?? verified[0]
|
||||||
|
const lower = Math.floor((h0.tick - 600) / h0.ts) * h0.ts
|
||||||
|
const upper = Math.ceil((h0.tick + 600) / h0.ts) * h0.ts
|
||||||
|
const amt0 = 10n ** 15n
|
||||||
|
const liq = getLiquidityForAmounts(h0.sqrtP, getSqrtRatioAtTick(lower), getSqrtRatioAtTick(upper), amt0, 2n ** 120n)
|
||||||
|
const mins = minAmountsForLiquidity(h0.sqrtP, getSqrtRatioAtTick(lower), getSqrtRatioAtTick(upper), liq, 100)
|
||||||
|
const data = encodeFunctionData({
|
||||||
|
abi: uniV3PmAbi,
|
||||||
|
functionName: 'mint',
|
||||||
|
args: [{ token0: h0.token0, token1: h0.token1, fee: h0.fee, tickLower: lower, tickUpper: upper, amount0Desired: amt0, amount1Desired: 2n ** 120n, amount0Min: mins.amount0Min, amount1Min: mins.amount1Min, recipient: '0x0000000000000000000000000000000000000001', deadline: 2n ** 40n }],
|
||||||
|
})
|
||||||
|
check('mint selector == canonical 0x88316456', data.slice(0, 10) === '0x88316456', data.slice(0, 10))
|
||||||
|
check('band-edge mins nonzero for in-range band', mins.amount0Min > 0n && mins.amount1Min > 0n, `${mins.amount0Min}/${mins.amount1Min}`)
|
||||||
|
|
||||||
|
console.log(fails === 0 ? '\nALL UNI-BROWSE SMOKE CHECKS PASSED' : `\n${fails} CHECKS FAILED`)
|
||||||
|
process.exit(fails === 0 ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
void main()
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { WagmiProvider, useAccount, useSwitchChain } from 'wagmi'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit'
|
||||||
|
import { robinhood } from './config/chain'
|
||||||
|
import { wagmiConfig } from './config/wagmi'
|
||||||
|
import { queryClient } from './config/query'
|
||||||
|
import { CHAIN_ID, EXPLORER } from './config/addresses'
|
||||||
|
import { currentLang } from './i18n'
|
||||||
|
import { Header, type TabId } from './components/Header'
|
||||||
|
import { LangControl } from './components/LangControl'
|
||||||
|
import { RpcControl } from './components/RpcControl'
|
||||||
|
import { ThemeControl } from './components/ThemeControl'
|
||||||
|
import { THEMES, useTheme } from './lib/theme'
|
||||||
|
import { TxLogPanel } from './components/TxLogPanel'
|
||||||
|
import { LabTab } from './components/tabs/LabTab'
|
||||||
|
import { PoolsTab } from './components/tabs/PoolsTab'
|
||||||
|
import { PositionsTab } from './components/tabs/PositionsTab'
|
||||||
|
import { SwapTab } from './components/tabs/SwapTab'
|
||||||
|
import { Btn } from './components/ui'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const theme = useTheme() // wallet modal accent follows the terminal theme
|
||||||
|
const { i18n } = useTranslation() // wallet modal language follows too
|
||||||
|
void i18n.language
|
||||||
|
return (
|
||||||
|
<WagmiProvider config={wagmiConfig}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RainbowKitProvider
|
||||||
|
theme={darkTheme({
|
||||||
|
accentColor: THEMES[theme].acc,
|
||||||
|
accentColorForeground: THEMES[theme].accFg,
|
||||||
|
borderRadius: 'none',
|
||||||
|
overlayBlur: 'small',
|
||||||
|
})}
|
||||||
|
locale={currentLang() === 'zh' ? 'zh-CN' : 'en-US'}
|
||||||
|
initialChain={robinhood}
|
||||||
|
modalSize="compact"
|
||||||
|
>
|
||||||
|
<Shell />
|
||||||
|
</RainbowKitProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</WagmiProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYS: Record<string, TabId> = { '1': 'pools', '2': 'positions', '3': 'swap' }
|
||||||
|
|
||||||
|
const validTab = (h: string): TabId | null => {
|
||||||
|
if (h === 'limit') return 'swap' // LIMIT mode is a sub-view of the swap tab
|
||||||
|
if (h === 'lab') return 'pools' // hidden component lab rides the pools slot
|
||||||
|
return (['pools', 'positions', 'swap'] as const).includes(h as TabId) ? (h as TabId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [tab, setTabState] = useState<TabId>(() => validTab(location.hash.slice(1)) ?? 'pools')
|
||||||
|
const setTab = (t: TabId) => {
|
||||||
|
setTabState(t)
|
||||||
|
history.replaceState(null, '', '#' + t)
|
||||||
|
}
|
||||||
|
const { isConnected, chainId } = useAccount()
|
||||||
|
const { switchChain } = useSwitchChain()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHash = () => {
|
||||||
|
const t = validTab(location.hash.slice(1))
|
||||||
|
if (t) setTabState(t)
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', onHash)
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
if (el && ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)) return
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||||
|
if (e.key === '4') {
|
||||||
|
// LIMIT is a sub-view of swap; location.hash fires hashchange so the
|
||||||
|
// mounted SwapTab syncs its mode too
|
||||||
|
setTabState('swap')
|
||||||
|
location.hash = 'limit'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = KEYS[e.key]
|
||||||
|
if (t) {
|
||||||
|
setTabState(t)
|
||||||
|
history.replaceState(null, '', '#' + t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', onHash)
|
||||||
|
window.removeEventListener('keydown', h)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Header tab={tab} onTab={setTab} />
|
||||||
|
<div className="main">
|
||||||
|
{isConnected && chainId !== CHAIN_ID && (
|
||||||
|
<div className="banner">
|
||||||
|
{t('app.wrongNetwork')}
|
||||||
|
<Btn onClick={() => switchChain({ chainId: CHAIN_ID })}>{t('app.switch')}</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'pools' && (location.hash === '#lab' ? <LabTab /> : <PoolsTab />)}
|
||||||
|
{tab === 'positions' && <PositionsTab />}
|
||||||
|
{tab === 'swap' && <SwapTab />}
|
||||||
|
</div>
|
||||||
|
<TxLogPanel />
|
||||||
|
<div className="footer">
|
||||||
|
<span>{t('app.tagline')}</span>
|
||||||
|
<span>{t('app.keys')}</span>
|
||||||
|
<RpcControl />
|
||||||
|
<ThemeControl />
|
||||||
|
<LangControl />
|
||||||
|
<a href={EXPLORER} target="_blank" rel="noreferrer">
|
||||||
|
{t('app.blockscout')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { parseAbi } from 'viem'
|
||||||
|
export { erc20Abi } from 'viem'
|
||||||
|
|
||||||
|
// Signatures below were extracted verbatim from Blockscout-verified ABIs.
|
||||||
|
// Only what the app calls is included.
|
||||||
|
|
||||||
|
export const wethAbi = parseAbi([
|
||||||
|
'function deposit() payable',
|
||||||
|
'function withdraw(uint256 wad)',
|
||||||
|
'function balanceOf(address) view returns (uint256)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const voterAbi = parseAbi([
|
||||||
|
'function length() view returns (uint256)',
|
||||||
|
'function pools(uint256) view returns (address)',
|
||||||
|
'function gauges(address pool) view returns (address)',
|
||||||
|
'function isAlive(address gauge) view returns (bool)',
|
||||||
|
'function weights(address pool) view returns (uint256)',
|
||||||
|
'function totalWeight() view returns (uint256)',
|
||||||
|
'function claimable(address gauge) view returns (uint256)',
|
||||||
|
'function capMode() view returns (uint8)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const minterAbi = parseAbi([
|
||||||
|
'function weekly() view returns (uint256)',
|
||||||
|
'function epochCount() view returns (uint256)',
|
||||||
|
'function activePeriod() view returns (uint256)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const v2FactoryAbi = parseAbi([
|
||||||
|
'function allPoolsLength() view returns (uint256)',
|
||||||
|
'function allPools(uint256) view returns (address)',
|
||||||
|
'function getPool(address tokenA, address tokenB, bool stable) view returns (address)',
|
||||||
|
'function getFee(address pool, bool _stable) view returns (uint256)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const v2PoolAbi = parseAbi([
|
||||||
|
'function metadata() view returns (uint256 dec0, uint256 dec1, uint256 r0, uint256 r1, bool st, address t0, address t1)',
|
||||||
|
'function totalSupply() view returns (uint256)',
|
||||||
|
'function balanceOf(address) view returns (uint256)',
|
||||||
|
'function getReserves() view returns (uint256 _reserve0, uint256 _reserve1, uint256 _blockTimestampLast)',
|
||||||
|
'function claimable0(address) view returns (uint256)',
|
||||||
|
'function claimable1(address) view returns (uint256)',
|
||||||
|
'function claimFees() returns (uint256 claimed0, uint256 claimed1)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const v2RouterAbi = parseAbi([
|
||||||
|
'function getAmountsOut(uint256 amountIn, (address from, address to, bool stable, address factory)[] routes) view returns (uint256[] amounts)',
|
||||||
|
'function quoteAddLiquidity(address tokenA, address tokenB, bool stable, address _factory, uint256 amountADesired, uint256 amountBDesired) view returns (uint256 amountA, uint256 amountB, uint256 liquidity)',
|
||||||
|
'function quoteRemoveLiquidity(address tokenA, address tokenB, bool stable, address _factory, uint256 liquidity) view returns (uint256 amountA, uint256 amountB)',
|
||||||
|
'function addLiquidity(address tokenA, address tokenB, bool stable, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB, uint256 liquidity)',
|
||||||
|
'function removeLiquidity(address tokenA, address tokenB, bool stable, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB)',
|
||||||
|
'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, (address from, address to, bool stable, address factory)[] routes, address to, uint256 deadline) returns (uint256[] amounts)',
|
||||||
|
])
|
||||||
|
|
||||||
|
// v2 Gauge instances are not individually verified on Blockscout; this is the
|
||||||
|
// standard Velodrome V2 Gauge interface. Selectors are validated against the
|
||||||
|
// live chain by scripts/smoke.ts (earned/balanceOf must respond via eth_call).
|
||||||
|
export const v2GaugeAbi = parseAbi([
|
||||||
|
'function balanceOf(address) view returns (uint256)',
|
||||||
|
'function totalSupply() view returns (uint256)',
|
||||||
|
'function earned(address _account) view returns (uint256)',
|
||||||
|
'function deposit(uint256 _amount)',
|
||||||
|
'function withdraw(uint256 _amount)',
|
||||||
|
'function getReward(address _account)',
|
||||||
|
'function rewardRate() view returns (uint256)',
|
||||||
|
'function periodFinish() view returns (uint256)',
|
||||||
|
'function stakingToken() view returns (address)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const clFactoryAbi = parseAbi([
|
||||||
|
'function allPoolsLength() view returns (uint256)',
|
||||||
|
'function allPools(uint256) view returns (address)',
|
||||||
|
'function getPool(address, address, int24) view returns (address)',
|
||||||
|
'function tickSpacings() view returns (int24[])',
|
||||||
|
'function tickSpacingToFee(int24) view returns (uint24)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const clPoolAbi = parseAbi([
|
||||||
|
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, bool unlocked)',
|
||||||
|
'function liquidity() view returns (uint128)',
|
||||||
|
'function stakedLiquidity() view returns (uint128)',
|
||||||
|
'function fee() view returns (uint24)',
|
||||||
|
'function unstakedFee() view returns (uint24)',
|
||||||
|
'function tickSpacing() view returns (int24)',
|
||||||
|
'function token0() view returns (address)',
|
||||||
|
'function token1() view returns (address)',
|
||||||
|
'function gauge() view returns (address)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const clPmAbi = parseAbi([
|
||||||
|
'function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, int24 tickSpacing, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)',
|
||||||
|
'function mint((address token0, address token1, int24 tickSpacing, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline, uint160 sqrtPriceX96) params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)',
|
||||||
|
'function increaseLiquidity((uint256 tokenId, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, uint256 deadline) params) payable returns (uint128 liquidity, uint256 amount0, uint256 amount1)',
|
||||||
|
'function decreaseLiquidity((uint256 tokenId, uint128 liquidity, uint256 amount0Min, uint256 amount1Min, uint256 deadline) params) payable returns (uint256 amount0, uint256 amount1)',
|
||||||
|
'function collect((uint256 tokenId, address recipient, uint128 amount0Max, uint128 amount1Max) params) payable returns (uint256 amount0, uint256 amount1)',
|
||||||
|
'function balanceOf(address owner) view returns (uint256)',
|
||||||
|
'function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)',
|
||||||
|
'function ownerOf(uint256 tokenId) view returns (address)',
|
||||||
|
'function getApproved(uint256 tokenId) view returns (address)',
|
||||||
|
'function approve(address to, uint256 tokenId)',
|
||||||
|
'function burn(uint256 tokenId) payable',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Note: CLGauge also has getReward(address); only the tokenId variant is included
|
||||||
|
// to keep viem overload resolution unambiguous.
|
||||||
|
export const clGaugeAbi = parseAbi([
|
||||||
|
'function deposit(uint256 tokenId)',
|
||||||
|
'function withdraw(uint256 tokenId)',
|
||||||
|
'function getReward(uint256 tokenId)',
|
||||||
|
'function earned(address account, uint256 tokenId) view returns (uint256)',
|
||||||
|
'function stakedValues(address depositor) view returns (uint256[] staked)',
|
||||||
|
'function rewardRate() view returns (uint256)',
|
||||||
|
'function periodFinish() view returns (uint256)',
|
||||||
|
])
|
||||||
|
|
||||||
|
// ---- Uniswap v3 (official Robinhood Chain deployment; see addresses.UNI) ----
|
||||||
|
// Same core math as Slipstream but fee-keyed: positions() carries uint24 fee
|
||||||
|
// where Slipstream has int24 tickSpacing, and slot0 has an extra feeProtocol
|
||||||
|
// word. increase/decrease/collect/burn are signature-identical to clPmAbi —
|
||||||
|
// call those fragments against UNI.V3_NPM instead of duplicating them here.
|
||||||
|
|
||||||
|
export const uniV3FactoryAbi = parseAbi([
|
||||||
|
'function getPool(address, address, uint24) view returns (address)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const uniV3PmAbi = parseAbi([
|
||||||
|
'function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)',
|
||||||
|
'function balanceOf(address owner) view returns (uint256)',
|
||||||
|
'function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)',
|
||||||
|
// mint is the ONE write that differs from Slipstream (uint24 fee instead of
|
||||||
|
// int24 tickSpacing, and no sqrtPriceX96 pool-creation field)
|
||||||
|
'function mint((address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline) params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const uniV3PoolAbi = parseAbi([
|
||||||
|
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
|
||||||
|
'function liquidity() view returns (uint128)',
|
||||||
|
'function tickSpacing() view returns (int24)',
|
||||||
|
'function token0() view returns (address)',
|
||||||
|
'function token1() view returns (address)',
|
||||||
|
'function fee() view returns (uint24)',
|
||||||
|
])
|
||||||
|
|
||||||
|
// ---- Uniswap v2 (official; UNI.V2_FACTORY / UNI.V2_ROUTER) ----
|
||||||
|
// Vanilla Uniswap v2 — NOT the Solidly-style UP33 v2 (no stable flag, 0.30%
|
||||||
|
// fixed fee). Pairs are fungible ERC-20 LP tokens, not NFTs.
|
||||||
|
|
||||||
|
export const uniV2FactoryAbi = parseAbi([
|
||||||
|
'function allPairsLength() view returns (uint256)',
|
||||||
|
'function allPairs(uint256) view returns (address)',
|
||||||
|
'function getPair(address, address) view returns (address)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const uniV2PairAbi = parseAbi([
|
||||||
|
'function token0() view returns (address)',
|
||||||
|
'function token1() view returns (address)',
|
||||||
|
'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
|
||||||
|
'function totalSupply() view returns (uint256)',
|
||||||
|
'function balanceOf(address) view returns (uint256)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const uniV2RouterAbi = parseAbi([
|
||||||
|
'function addLiquidity(address tokenA, address tokenB, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB, uint256 liquidity)',
|
||||||
|
'function removeLiquidity(address tokenA, address tokenB, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB)',
|
||||||
|
])
|
||||||
|
|
||||||
|
// On-chain the quoter fns are nonpayable (revert-and-catch quoting); declared
|
||||||
|
// view here so they can ride eth_call/multicall — semantics are identical.
|
||||||
|
export const quoterAbi = parseAbi([
|
||||||
|
'function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, int24 tickSpacing, uint160 sqrtPriceLimitX96) params) view returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const clSwapRouterAbi = parseAbi([
|
||||||
|
'function exactInputSingle((address tokenIn, address tokenOut, int24 tickSpacing, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)',
|
||||||
|
])
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flashes its children green (value went up) or red (down) whenever the
|
||||||
|
* tracked number changes — direction is visible at a glance. Pass `arrow`
|
||||||
|
* to also show a transient ▲/▼ next to the value.
|
||||||
|
*/
|
||||||
|
export function Flash(props: { v: number | null | undefined; arrow?: boolean; children: ReactNode }) {
|
||||||
|
const { v } = props
|
||||||
|
const prev = useRef<number | null | undefined>(v)
|
||||||
|
const [dir, setDir] = useState<1 | -1 | 0>(0)
|
||||||
|
const [nonce, setNonce] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const a = prev.current
|
||||||
|
prev.current = v
|
||||||
|
if (typeof v !== 'number' || typeof a !== 'number' || !Number.isFinite(v) || !Number.isFinite(a)) return
|
||||||
|
const eps = Math.abs(a) * 1e-9 + 1e-15 // ignore float jitter
|
||||||
|
if (Math.abs(v - a) <= eps) return
|
||||||
|
setDir(v > a ? 1 : -1)
|
||||||
|
setNonce((n) => n + 1)
|
||||||
|
}, [v])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dir === 0) return
|
||||||
|
const t = setTimeout(() => setDir(0), 1200)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [dir, nonce])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={nonce} className={`flash ${dir === 1 ? 'flash-up' : dir === -1 ? 'flash-down' : ''}`}>
|
||||||
|
{props.children}
|
||||||
|
{props.arrow && dir !== 0 && (
|
||||||
|
<span className={`flash-arrow ${dir === 1 ? 'green' : 'red'}`}>{dir === 1 ? '▲' : '▼'}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ConnectButton } from '@rainbow-me/rainbowkit'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { fmtDur } from '../lib/format'
|
||||||
|
import { useEpoch } from '../hooks/useEpoch'
|
||||||
|
import { usePools } from '../hooks/usePools'
|
||||||
|
|
||||||
|
export type TabId = 'pools' | 'positions' | 'swap'
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'pools', labelKey: 'hdr.pools', key: '1' },
|
||||||
|
{ id: 'positions', labelKey: 'hdr.positions', key: '2' },
|
||||||
|
{ id: 'swap', labelKey: 'hdr.swap', key: '3' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function Header(props: { tab: TabId; onTab: (t: TabId) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const epoch = useEpoch()
|
||||||
|
const pools = usePools()
|
||||||
|
const p = pools.data?.protocol
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hdr">
|
||||||
|
<span className="brand">
|
||||||
|
LP<span className="cursor">▮</span>TERMINAL
|
||||||
|
</span>
|
||||||
|
<div className="tabs">
|
||||||
|
{TABS.map((tb) => (
|
||||||
|
<button
|
||||||
|
key={tb.id}
|
||||||
|
className={`tab ${props.tab === tb.id ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onTab(tb.id)}
|
||||||
|
>
|
||||||
|
<span className="key">[{tb.key}]</span>
|
||||||
|
{t(tb.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="hdr-meta">
|
||||||
|
{t('hdr.epoch')} <b>{p ? p.epochCount : '…'}</b> · {t('hdr.flip')} <b>{fmtDur(epoch.secsLeft)}</b>
|
||||||
|
{p ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· {t('hdr.blk')} <b>{p.blockNumber.toString()}</b>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<ConnectButton.Custom>
|
||||||
|
{({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => {
|
||||||
|
if (!mounted) return <button className="btn ghost">…</button>
|
||||||
|
if (!account)
|
||||||
|
return (
|
||||||
|
<button className="btn" onClick={openConnectModal}>
|
||||||
|
{t('hdr.connect')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
if (chain?.unsupported)
|
||||||
|
return (
|
||||||
|
<button className="btn danger" onClick={openChainModal}>
|
||||||
|
{t('hdr.wrongChain')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<button className="btn ghost" onClick={openAccountModal}>
|
||||||
|
[{account.displayName}
|
||||||
|
{account.displayBalance ? ` · ${account.displayBalance}` : ''}]
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</ConnectButton.Custom>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { currentLang, setLang, type Lang } from '../i18n'
|
||||||
|
|
||||||
|
const LABELS: Record<Lang, string> = { en: 'EN', zh: '中文' }
|
||||||
|
|
||||||
|
/** footer language switcher — instant, persisted, wallet modal follows */
|
||||||
|
export function LangControl() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
void i18n.language // subscribe: re-render on language change
|
||||||
|
const cur = currentLang()
|
||||||
|
return (
|
||||||
|
<span className="theme-ctl">
|
||||||
|
<span className="dim">{t('lang.label')}</span>
|
||||||
|
{(Object.keys(LABELS) as Lang[]).map((l) => (
|
||||||
|
<button key={l} className={`chip ${cur === l ? 'on' : ''}`} onClick={() => setLang(l)}>
|
||||||
|
{LABELS[l]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Protocol identity badges: colored label + brand mark, terminal-sized.
|
||||||
|
// Uniswap mark: official brand-assets icon (paths verbatim), fill follows the
|
||||||
|
// badge color via currentColor. UP33 mark: UP token icon (1.5 kB webp) inlined
|
||||||
|
// so the page stays fully self-contained under CSP.
|
||||||
|
import type { LpProtocol } from '../types'
|
||||||
|
|
||||||
|
const UP_ICON =
|
||||||
|
'data:image/webp;base64,UklGRhgGAABXRUJQVlA4IAwGAAAQMQCdASoAAQABPi0WikOhoSERytxQGALEs7d5T6F42YNsbpv4o/FPwr2kjk/4b/gZ1lPI3zX8zfrXmAXBv8c/I/+gf///6fhj9AOkA/R3+NfkB/Hf/////BP5gP0l/tf8A97T+d/4D+Z+QB8gH/I/gHq+dQB/AP6r///V8/cz4PP2X/0X9P9n77////3gHUj8QJmTw4yrKGbmM7VXSAPWm0CKg36ddSL9Vf////xLPGiRQUEkUFBJFBQSRQUEkUFBJFBQSRQUEeWcP8X7WYUFBJE92+p4zKsKumfRFWJ/2jqx8Jr7HljTn49LE1EdvVv+0dWPjggxkZbkuDhnqKxP7PfxqltHl5+9OXpcBnpAwnOBOa5BEaanjUFHkzN4VLzgxMOuFhOHgVrj4huDpDUJzLiGrcId2i/HNo/DicP+tO/J2IxbbPfyHdgHju8/LaA116wUSzik6aVBMP4+0dWQDTp/ArG9ETfuwpj7R1Y9YXgptuLB1Cx9m1cfZtXH2bVx9m1cfZtXH2bVx9m1cfZpAAD+/7j304hB2iHy9BAACho5AJk3uxACbkAEhLI5py68pYMA9reTMQB5iWT40LoBA86CqxEWaMN4KW8nVDBnub9VqD0VNbgLnMLSxlp4ziYWCR8EUug2kpuY+4bjOquQAVvCKnAW7r91WvrzHu8NYW9DjsRP4AG8ADAVmgua3uUJ6HWW2heCBj4lhQxSUWHvqLx+jCae7urB4RPKq5aLXddtdgwHwFY0wnXkC/u5zFI0qAPzpOWRbBXB+oNtHDu1vg7XiLlSIep9acBudJSNEWgsja4+YUXTD/HsFIPI1gfZQ03f6xvxRPtsUGgBxT6gw/k5NZvV39AXj+IbL/RtkFXyF/hRhlo/vg+s+hqPs298H1l/NR9k/aAC6XoRIDo4NqUJ+0pZsVmuSW+rpF/yGfkwG5jCCZEiugSkdpwlH3uuTpN7n91NlOFKa90fS7TbqnH83+n+slvjmEPEATJ3zM+1MyMTPEHN4PTQXTcn8lubTXLc9yK41Tr2keJoiMsmbP8OcwAH2/JCXz3X3X/Xv/wefiYDSZJt+6jtJW1BLt48vlZgPGZzXkPkOcq6HEL/DVnTvC5lfNmUov5g3uYlAIzxkxXFJfMEIdk9MyLILXMWk8zIeO5dGG3HGLAK++D6y+ro+zb3wfWX1dH2T9oAL82HGs6a+FtHCmeh8aYJkMMHD2wcxvP/lfYP0r65VXFoxnT2vJAImSO2i1kTSfQNhyIaba9M8q5nAUIk/oX2Fx0KgKLQ6Oo+oJhRg2SDPEnNAeaybjcuhbmLzz7dYBLaFQQhgEGMLQXZLcSKEcVsIfvkAZEfR88IpgC1cFrmLOjHPI+MJ4PHyDcMxuWqi0OAE9A7qFgrSLAGGnCQo+Q/s5Sc4SFHsf9nKNn0g4/IQaVwXqQJl1WHTaT9mrNT+/iCFfYK54tVAid/qhkohOATBCPjTZQq62P2+qouNgiQciD6qwvrTgZHwi6e6liY0kPZB8ZBNbNU69mxi6/2EkvmkjCdQ6w8JdWsEgfY7Fkw6Vo8orRhxFirMUDzbEcZQF5vZS/mXQ+xSJIZI/dxyJOyk8Z48JbmqroHpcI++AZm76Acp7tn6HXcs7fVoyTVKnYf2mZqC3ouRad7/8TOv/lX/naNTR9eUOEhR8h/a+6JrxWjvxZtw7cw497uD8SeDocAvv8h8BWB+T/zj+8jZbz85weoE3oikAip2GgL0qApxzFf0FN/15rQS9x1kaU0Y0gS+PZ1XEwI38xwemFlyIf0HilWsJi1hvUTLDWEvfhnRq9CA/5ednBlw29Nn/CUeumeG90sanuxf5GqLsz6gS4ku+A/5Kz8P9aEo/PZO9bk5m2zzlj4LZ3swPIC18pfOAmtKmkxrgtWZD+P6Xm/4NxUcDPyTeTs5xjTsvUu+3h+64GK3STDepYLoE4FiU+dsj7OvHLaRV65OsdvvTPMkcYPlD/xT/4NWx/4u+3RmGlgk0L8ve+f5/62/+Hn8Ybhtdznmr1M6x1llRR0LzEenVSTc3qyRYwu3AiYAAAAAAA='
|
||||||
|
|
||||||
|
function UniMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 400 434" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M325.144 65.7698C325.727 55.5419 327.119 48.7957 329.918 42.6348C331.025 40.1962 332.063 38.2003 332.223 38.2003C332.383 38.2003 331.902 40.0003 331.153 42.1999C329.118 48.1791 328.784 56.3572 330.186 65.872C331.965 77.9447 332.976 79.6866 345.782 92.7278C351.788 98.8446 358.775 106.559 361.307 109.871L365.912 115.894L361.307 111.59C355.676 106.327 342.724 96.0618 339.863 94.5945C337.945 93.6104 337.66 93.6274 336.477 94.801C335.387 95.8823 335.157 97.5072 335.005 105.189C334.77 117.16 333.132 124.845 329.18 132.528C327.042 136.684 326.705 135.797 328.64 131.106C330.084 127.604 330.231 126.064 330.22 114.475C330.198 91.1888 327.424 85.5906 311.153 76.0005C307.032 73.5711 300.24 70.0674 296.062 68.2141C291.883 66.3608 288.564 64.7467 288.685 64.6261C289.146 64.1691 305.013 68.7839 311.399 71.2318C320.899 74.8731 322.467 75.3449 323.621 74.9057C324.394 74.6113 324.768 72.367 325.144 65.7698Z" />
|
||||||
|
<path d="M124.431 31.8931C118.795 31.0236 118.557 30.9216 121.209 30.5162C126.292 29.7389 138.292 30.7983 146.562 32.754C165.867 37.3182 183.434 49.0103 202.186 69.7754L207.168 75.2919L214.295 74.1524C244.318 69.3537 274.861 73.1675 300.407 84.9049C307.435 88.134 318.516 94.5618 319.9 96.2136C320.341 96.7401 321.151 100.129 321.699 103.745C323.597 116.255 322.647 125.843 318.8 133.005C316.707 136.903 316.59 138.138 317.997 141.473C319.121 144.135 322.253 146.105 325.355 146.1C331.703 146.092 338.536 135.885 341.702 121.683L342.96 116.042L345.452 118.849C359.117 134.248 369.85 155.248 371.694 170.196L372.174 174.093L369.877 170.55C365.924 164.454 361.952 160.303 356.866 156.956C347.697 150.922 338.002 148.868 312.327 147.523C289.139 146.307 276.015 144.337 263.002 140.117C240.861 132.937 229.699 123.375 203.399 89.0559C191.717 73.8123 184.497 65.3784 177.314 58.5864C160.994 43.1536 144.958 35.0599 124.431 31.8931Z" />
|
||||||
|
<path d="M135.377 105.713C123.937 90.0473 116.858 66.0289 118.391 48.0742L118.864 42.518L121.469 42.9906C126.359 43.8778 134.791 46.9992 138.739 49.3844C149.574 55.9291 154.265 64.5458 159.037 86.6722C160.435 93.1531 162.269 100.487 163.113 102.97C164.472 106.967 169.605 116.302 173.779 122.365C176.785 126.731 174.788 128.8 168.143 128.204C157.994 127.293 144.246 117.856 135.377 105.713Z" />
|
||||||
|
<path d="M311.254 222.301C257.789 200.891 238.958 182.307 238.958 150.95C238.958 146.336 239.118 142.56 239.313 142.56C239.507 142.56 241.576 144.083 243.909 145.945C254.751 154.593 266.891 158.287 300.501 163.164C320.279 166.034 331.408 168.352 341.675 171.738C374.308 182.502 394.497 204.345 399.312 234.098C400.711 242.743 399.891 258.955 397.623 267.5C395.832 274.248 390.367 286.413 388.918 286.879C388.516 287.009 388.122 285.477 388.019 283.393C387.469 272.221 381.793 261.345 372.26 253.197C361.421 243.934 346.859 236.559 311.254 222.301Z" />
|
||||||
|
<path d="M273.719 231.195C273.049 227.231 271.888 222.168 271.138 219.944L269.773 215.901L272.307 218.73C275.814 222.645 278.585 227.654 280.933 234.325C282.726 239.417 282.927 240.932 282.914 249.206C282.9 257.33 282.676 259.032 281.021 263.616C278.411 270.843 275.172 275.967 269.737 281.468C259.971 291.354 247.415 296.828 229.295 299.098C226.145 299.492 216.965 300.157 208.895 300.574C188.556 301.626 175.17 303.8 163.142 308C161.412 308.605 159.868 308.972 159.711 308.816C159.225 308.334 167.414 303.479 174.178 300.238C183.716 295.668 193.211 293.175 214.484 289.651C224.992 287.91 235.844 285.799 238.6 284.958C264.624 277.023 278.001 256.544 273.719 231.195Z" />
|
||||||
|
<path d="M298.228 274.486C291.125 259.299 289.493 244.635 293.385 230.959C293.802 229.498 294.471 228.302 294.874 228.302C295.276 228.302 296.953 229.203 298.598 230.305C301.872 232.496 308.437 236.188 325.928 245.672C347.754 257.507 360.198 266.672 368.66 277.142C376.071 286.312 380.658 296.755 382.865 309.49C384.115 316.704 383.383 334.061 381.521 341.325C375.653 364.229 362.015 382.22 342.562 392.718C339.712 394.256 337.153 395.519 336.876 395.524C336.599 395.53 337.638 392.904 339.184 389.688C345.728 376.083 346.473 362.849 341.526 348.118C338.496 339.099 332.32 328.094 319.849 309.493C305.35 287.868 301.795 282.113 298.228 274.486Z" />
|
||||||
|
<path d="M97.4084 356.432C117.249 339.771 141.935 327.934 164.422 324.301C174.113 322.735 190.257 323.357 199.231 325.641C213.616 329.302 226.484 337.503 233.176 347.273C239.716 356.823 242.522 365.144 245.443 383.659C246.596 390.962 247.849 398.296 248.228 399.957C250.422 409.552 254.691 417.222 259.982 421.074C268.385 427.191 282.854 427.571 297.087 422.049C299.503 421.111 301.6 420.464 301.748 420.61C302.264 421.12 295.097 425.892 290.04 428.404C283.237 431.784 277.827 433.09 270.638 433.09C257.602 433.09 246.779 426.495 237.748 413.046C235.971 410.399 231.976 402.471 228.871 395.429C219.336 373.799 214.628 367.21 203.556 359.998C193.922 353.723 181.496 352.599 172.148 357.158C159.87 363.146 156.444 378.752 165.238 388.642C168.733 392.573 175.251 395.964 180.581 396.623C190.552 397.856 199.121 390.312 199.121 380.299C199.121 373.798 196.609 370.088 190.284 367.249C181.646 363.372 172.36 367.904 172.405 375.975C172.424 379.417 173.931 381.578 177.4 383.139C179.625 384.14 179.677 384.22 177.862 383.845C169.936 382.21 168.079 372.707 174.452 366.398C182.103 358.824 197.924 362.166 203.357 372.504C205.64 376.847 205.905 385.495 203.915 390.717C199.461 402.405 186.475 408.552 173.302 405.207C164.333 402.93 160.681 400.464 149.867 389.386C131.077 370.135 123.782 366.405 96.6926 362.198L91.5016 361.393L97.4084 356.432Z" />
|
||||||
|
<path d="M9.2415 11.8782C71.9927 87.6272 115.213 118.88 120.016 125.483C123.981 130.935 122.489 135.836 115.695 139.678C111.917 141.813 104.15 143.977 100.261 143.977C95.8623 143.977 94.3522 142.29 94.3522 142.29C91.8018 139.884 90.3654 140.305 77.2685 117.183C59.0858 89.1221 43.8693 65.8442 43.4542 65.4545C42.4943 64.5531 42.5108 64.5836 75.4146 123.138C80.7309 135.342 76.472 139.822 76.472 141.56C76.472 145.096 75.5021 146.955 71.1164 151.82C63.8053 159.933 60.537 169.047 58.1777 187.911C55.5328 209.058 48.0957 223.995 27.485 249.56C15.4202 264.526 13.446 267.269 10.4018 273.3C6.56722 280.896 5.51286 285.15 5.08553 294.742C4.63386 304.883 5.51354 311.434 8.62869 321.13C11.3559 329.619 14.2026 335.224 21.4801 346.435C27.7605 356.109 31.3768 363.299 31.3768 366.111C31.3768 368.349 31.8063 368.352 41.5383 366.166C64.8282 360.936 83.7395 351.736 94.3756 340.462C100.958 333.483 102.503 329.63 102.554 320.067C102.587 313.812 102.365 312.503 100.665 308.904C97.898 303.048 92.8603 298.178 81.7568 290.629C67.2082 280.737 60.9944 272.774 59.2779 261.822C57.8699 252.836 59.5033 246.496 67.5518 229.719C75.8822 212.353 77.9466 204.953 79.3431 187.449C80.245 176.141 81.4941 171.681 84.761 168.101C88.1682 164.368 91.2354 163.104 99.6675 161.958C113.414 160.09 122.168 156.552 129.363 149.958C135.605 144.238 138.217 138.725 138.618 130.427L138.922 124.138L135.434 120.093C122.802 105.445 0.782165 0.363281 0.00480791 0.363281C-0.161257 0.363281 3.99542 5.54535 9.2415 11.8782ZM38.4675 306.217C41.3235 301.186 39.8061 294.719 35.0288 291.56C30.515 288.574 23.503 289.98 23.503 293.87C23.503 295.057 24.163 295.921 25.6504 296.683C28.155 297.965 28.3368 299.406 26.3662 302.352C24.3705 305.335 24.5316 307.958 26.8207 309.741C30.5099 312.614 35.7324 311.034 38.4675 306.217Z" />
|
||||||
|
<path d="M147.597 165.257C141.144 167.228 134.87 174.031 132.928 181.164C131.744 185.515 132.416 193.149 134.19 195.506C137.057 199.314 139.829 200.317 147.336 200.264C162.033 200.162 174.809 193.893 176.294 186.056C177.512 179.632 171.901 170.729 164.172 166.82C160.183 164.804 151.701 164.004 147.597 165.257ZM164.778 178.619C167.044 175.416 166.053 171.954 162.198 169.613C154.858 165.155 143.757 168.844 143.757 175.741C143.757 179.174 149.546 182.92 154.852 182.92C158.384 182.92 163.217 180.825 164.778 178.619Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mini: logo-only glyph for dense table rows (full badge = label + box) */
|
||||||
|
export function ProtoBadge({ proto, mini }: { proto: LpProtocol; mini?: boolean }) {
|
||||||
|
const uni = proto === 'univ3' || proto === 'univ2'
|
||||||
|
const title = uni
|
||||||
|
? `Uniswap ${proto === 'univ3' ? 'v3' : 'v2'} (official Robinhood Chain deployment)`
|
||||||
|
: 'UP33 ve(3,3) position'
|
||||||
|
if (mini)
|
||||||
|
return (
|
||||||
|
<span className={`proto-mini ${uni ? 'uni' : 'up33'}`} title={title}>
|
||||||
|
{uni ? <UniMark /> : <img src={UP_ICON} alt="" aria-hidden="true" />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
if (uni)
|
||||||
|
return (
|
||||||
|
<span className="badge proto uni" title={title}>
|
||||||
|
<UniMark />
|
||||||
|
UNISWAP
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<span className="badge proto up33" title={title}>
|
||||||
|
<img src={UP_ICON} alt="" aria-hidden="true" />
|
||||||
|
UP33
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { sqrtPriceToPrice, tickToPrice } from '../lib/clmath'
|
||||||
|
import { fmtNum } from '../lib/format'
|
||||||
|
import { Flash } from './Flash'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LP range bar:
|
||||||
|
* [lower price] ▕░░▓▓▓▓▓┃▓▓░░▏ [upper price]
|
||||||
|
* - both ends are the position's price bounds
|
||||||
|
* - the marker is the CURRENT pool price
|
||||||
|
* - shows drift: % move to each bound, % position inside the range, and when
|
||||||
|
* out of range, the move needed to re-enter.
|
||||||
|
* Marker position is linear in tick space; a padding zone on both sides makes
|
||||||
|
* out-of-range drift visible instead of clamping at the border.
|
||||||
|
*/
|
||||||
|
export function RangeBar(props: {
|
||||||
|
tickLower: number
|
||||||
|
tickUpper: number
|
||||||
|
tick: number
|
||||||
|
sqrtPriceX96: bigint
|
||||||
|
dec0: number
|
||||||
|
dec1: number
|
||||||
|
sym0: string
|
||||||
|
sym1: string
|
||||||
|
/** order mode: this position is a range order — out-of-range is the intended
|
||||||
|
* resting state, so relabel the status instead of alarming in red */
|
||||||
|
order?: { fillFrac: number; sellSym: string; buySym: string }
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
// order mode defaults to the SELL token's price orientation ("fills as it rises")
|
||||||
|
const [flipped, setFlipped] = useState(props.order ? props.order.sellSym === props.sym1 : false)
|
||||||
|
const { tickLower, tickUpper, tick, sqrtPriceX96, dec0, dec1, sym0, sym1, order } = props
|
||||||
|
|
||||||
|
// prices in token1-per-token0 orientation
|
||||||
|
const pLower = tickToPrice(tickLower, dec0, dec1)
|
||||||
|
const pUpper = tickToPrice(tickUpper, dec0, dec1)
|
||||||
|
const pCur = sqrtPriceToPrice(sqrtPriceX96, dec0, dec1)
|
||||||
|
|
||||||
|
// displayed orientation
|
||||||
|
const dLower = flipped ? 1 / pUpper : pLower
|
||||||
|
const dUpper = flipped ? 1 / pLower : pUpper
|
||||||
|
const dCur = flipped ? 1 / pCur : pCur
|
||||||
|
const base = flipped ? sym1 : sym0
|
||||||
|
const quote = flipped ? sym0 : sym1
|
||||||
|
|
||||||
|
// marker fraction, linear in ticks, padded 15% each side
|
||||||
|
const width = tickUpper - tickLower
|
||||||
|
const pad = Math.max(1, Math.round(width * 0.15))
|
||||||
|
const lo = tickLower - pad
|
||||||
|
const hi = tickUpper + pad
|
||||||
|
let frac = (tick - lo) / (hi - lo)
|
||||||
|
if (flipped) frac = 1 - frac
|
||||||
|
const fracPct = Math.min(99.5, Math.max(0.5, frac * 100))
|
||||||
|
const winLeft = (pad / (hi - lo)) * 100
|
||||||
|
const winWidth = (width / (hi - lo)) * 100
|
||||||
|
|
||||||
|
const inRange = tick >= tickLower && tick < tickUpper
|
||||||
|
const posPct = ((tick - tickLower) / width) * 100
|
||||||
|
const nearEdge = inRange && (posPct < 12 || posPct > 88)
|
||||||
|
const tone = order ? '' : !inRange ? 'red' : nearEdge ? 'amber' : ''
|
||||||
|
|
||||||
|
// phosphor trail: on a real price move, smear a decaying gradient over the
|
||||||
|
// path the marker just glided across (oscilloscope persistence). Suppressed
|
||||||
|
// on flip (display-space jump, price didn't move) and on sub-pixel jitter.
|
||||||
|
const prevRef = useRef<{ pct: number; tick: number; flipped: boolean } | null>(null)
|
||||||
|
const [trail, setTrail] = useState<{ left: number; width: number; rev: boolean; n: number } | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevRef.current
|
||||||
|
prevRef.current = { pct: fracPct, tick, flipped }
|
||||||
|
if (!prev || prev.flipped !== flipped || prev.tick === tick) return
|
||||||
|
const d = fracPct - prev.pct
|
||||||
|
if (Math.abs(d) < 0.4) return
|
||||||
|
setTrail((tr) => ({ left: Math.min(prev.pct, fracPct), width: Math.abs(d), rev: d < 0, n: (tr?.n ?? 0) + 1 }))
|
||||||
|
}, [fracPct, tick, flipped])
|
||||||
|
|
||||||
|
// out of range: a dashed excursion line from the window edge to the marker
|
||||||
|
// makes "how far out" visible (glides with the marker). Not in order mode —
|
||||||
|
// resting outside the band is the intended state there.
|
||||||
|
const winRight = winLeft + winWidth
|
||||||
|
const exc =
|
||||||
|
!order && !inRange
|
||||||
|
? fracPct < winLeft
|
||||||
|
? { left: fracPct, width: winLeft - fracPct }
|
||||||
|
: { left: winRight, width: Math.max(0, fracPct - winRight) }
|
||||||
|
: null
|
||||||
|
// near-edge: mark the threatened bound post amber (display-space side)
|
||||||
|
const warnSide = !order && nearEdge ? ((posPct < 12) !== flipped ? ' warn-lo' : ' warn-hi') : ''
|
||||||
|
|
||||||
|
// % price move (in displayed units) to each bound
|
||||||
|
const toLeft = (dLower / dCur - 1) * 100
|
||||||
|
const toRight = (dUpper / dCur - 1) * 100
|
||||||
|
// band half-width, geometric: ±x% around mid
|
||||||
|
const bandPct = (Math.sqrt(dUpper / dLower) - 1) * 100
|
||||||
|
|
||||||
|
let statusText: string
|
||||||
|
let statusTone: string
|
||||||
|
if (order) {
|
||||||
|
if (order.fillFrac >= 0.999) {
|
||||||
|
statusText = t('rbar.orderFilled', { sym: order.buySym })
|
||||||
|
statusTone = 'green'
|
||||||
|
} else if (inRange || order.fillFrac > 0.001) {
|
||||||
|
statusText = t('rbar.orderFilling', { pct: (order.fillFrac * 100).toFixed(1), sym: order.sellSym })
|
||||||
|
statusTone = 'amber'
|
||||||
|
} else {
|
||||||
|
const need = fmtNum(dCur < dLower ? toLeft : Math.abs(toRight), 3)
|
||||||
|
statusText = dCur < dLower ? t('rbar.orderWaitRise', { pct: need }) : t('rbar.orderWaitFall', { pct: need })
|
||||||
|
statusTone = 'cyan'
|
||||||
|
}
|
||||||
|
} else if (inRange) {
|
||||||
|
statusText = t('rbar.inRange', { pct: posPct.toFixed(1), band: fmtNum(bandPct, 3) })
|
||||||
|
statusTone = nearEdge ? 'amber' : 'green'
|
||||||
|
} else if (dCur < dLower) {
|
||||||
|
statusText = t('rbar.outRise', { pct: fmtNum(toLeft, 3) })
|
||||||
|
statusTone = 'red'
|
||||||
|
} else {
|
||||||
|
statusText = t('rbar.outFall', { pct: fmtNum(Math.abs(toRight), 3) })
|
||||||
|
statusTone = 'red'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rbar-wrap">
|
||||||
|
<div className="rbar">
|
||||||
|
<span className="rbar-price">{fmtNum(dLower)}</span>
|
||||||
|
<div
|
||||||
|
className="rbar-track"
|
||||||
|
title={t('rbar.ticksTip', { lo: tickLower, hi: tickUpper, tick })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rbar-window${!order && !inRange ? ' out' : ''}${warnSide}`}
|
||||||
|
style={{ left: `${winLeft}%`, width: `${winWidth}%` }}
|
||||||
|
/>
|
||||||
|
{trail && (
|
||||||
|
<div
|
||||||
|
key={trail.n}
|
||||||
|
className={`rbar-trail${trail.rev ? ' rev' : ''} ${tone}`}
|
||||||
|
style={{ left: `${trail.left}%`, width: `${trail.width}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{exc && exc.width > 0.2 && (
|
||||||
|
<div className="rbar-exc" style={{ left: `${exc.left}%`, width: `${exc.width}%` }} />
|
||||||
|
)}
|
||||||
|
<div className={`rbar-marker ${tone}`} style={{ left: `${fracPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="rbar-price">{fmtNum(dUpper)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rbar-sub">
|
||||||
|
<span className={!order && !inRange && dCur < dLower ? 'red' : 'dim'}>
|
||||||
|
{toLeft >= 0 ? '+' : ''}
|
||||||
|
{fmtNum(toLeft, 3)}% {t('rbar.toLow')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
px{' '}
|
||||||
|
<Flash v={dCur} arrow>
|
||||||
|
<span className={tone || 'green'}>{fmtNum(dCur)}</span>
|
||||||
|
</Flash>{' '}
|
||||||
|
<span className="dim">
|
||||||
|
{quote}/{base}
|
||||||
|
</span>
|
||||||
|
<button className="rbar-flip" title={t('rbar.flipTip')} onClick={() => setFlipped(!flipped)}>
|
||||||
|
⇄
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span className={!order && !inRange && dCur > dUpper ? 'red' : 'dim'}>
|
||||||
|
{t('rbar.fromHigh')} {toRight >= 0 ? '+' : ''}
|
||||||
|
{fmtNum(toRight, 3)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rbar-sub">
|
||||||
|
<Flash v={order ? order.fillFrac : undefined} arrow>
|
||||||
|
<span className={statusTone}>{statusText}</span>
|
||||||
|
</Flash>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// footer control: view / override the chain-read RPC endpoint per browser.
|
||||||
|
// The choice is stored in localStorage and applied on reload (the wagmi
|
||||||
|
// transport is resolved once at startup).
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { CHAIN_ID } from '../config/addresses'
|
||||||
|
import { ENV } from '../config/env'
|
||||||
|
import { customRpc, isValidRpcUrl, probeRpc, setCustomRpc } from '../lib/rpcPref'
|
||||||
|
|
||||||
|
export function RpcControl() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [val, setVal] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const cur = customRpc()
|
||||||
|
const label = cur ? 'CUSTOM' : ENV.rpcUrl ? 'ENV' : 'DEFAULT'
|
||||||
|
|
||||||
|
if (!open)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="rpc-toggle"
|
||||||
|
title={t('rpc.toggleTip')}
|
||||||
|
onClick={() => {
|
||||||
|
setVal(cur)
|
||||||
|
setErr('')
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
rpc:<span className={cur ? 'green' : ''}>{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const v = val.trim()
|
||||||
|
const valid = v === '' || isValidRpcUrl(v)
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
if (!valid || busy) return
|
||||||
|
if (v === '') {
|
||||||
|
setCustomRpc('')
|
||||||
|
location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setErr('')
|
||||||
|
const res = await probeRpc(v, CHAIN_ID)
|
||||||
|
setBusy(false)
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(res.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCustomRpc(v)
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="rpc-edit">
|
||||||
|
<span className="dim">rpc:</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ width: 300 }}
|
||||||
|
value={val}
|
||||||
|
autoFocus
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={t('rpc.placeholder')}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVal(e.target.value)
|
||||||
|
setErr('')
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void apply()
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="chip" disabled={!valid || busy} onClick={() => void apply()}>
|
||||||
|
{busy ? t('rpc.checking') : t('rpc.apply')}
|
||||||
|
</button>
|
||||||
|
{cur && (
|
||||||
|
<button
|
||||||
|
className="chip"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomRpc('')
|
||||||
|
location.reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('rpc.reset')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="chip" onClick={() => setOpen(false)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{err ? <span className="red">{err}</span> : !valid ? <span className="red">{t('rpc.notHttp')}</span> : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { THEMES, applyTheme, useTheme, type ThemeId } from '../lib/theme'
|
||||||
|
|
||||||
|
/** footer theme switcher — instant, persisted, also restyles the wallet modal */
|
||||||
|
export function ThemeControl() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const cur = useTheme()
|
||||||
|
return (
|
||||||
|
<span className="theme-ctl">
|
||||||
|
<span className="dim">{t('theme.label')}</span>
|
||||||
|
{(Object.keys(THEMES) as ThemeId[]).map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className={`chip ${cur === id ? 'on' : ''}`}
|
||||||
|
onClick={() => applyTheme(id)}
|
||||||
|
title={t('theme.switchTip', { name: id })}
|
||||||
|
>
|
||||||
|
{THEMES[id].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { Address } from 'viem'
|
||||||
|
import { NATIVE } from '../lib/kyber'
|
||||||
|
import { shortAddr } from '../lib/format'
|
||||||
|
import type { TokenInfo } from '../types'
|
||||||
|
|
||||||
|
export function TokenSelect(props: {
|
||||||
|
list: TokenInfo[]
|
||||||
|
value: TokenInfo
|
||||||
|
exclude?: Address
|
||||||
|
onChange: (t: TokenInfo) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const ex = props.exclude?.toLowerCase()
|
||||||
|
let l = props.list.filter((t) => t.address.toLowerCase() !== ex)
|
||||||
|
if (q) {
|
||||||
|
const s = q.toLowerCase()
|
||||||
|
l = l.filter((t) => t.symbol.toLowerCase().includes(s) || t.address.toLowerCase() === s)
|
||||||
|
}
|
||||||
|
return l.slice(0, 80)
|
||||||
|
}, [props.list, props.exclude, q])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tsel">
|
||||||
|
<button className="tsel-btn" onClick={() => setOpen(!open)}>
|
||||||
|
{props.value.symbol} ▾
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="tsel-backdrop" onClick={() => setOpen(false)} />
|
||||||
|
<div className="tsel-pop">
|
||||||
|
<div className="filter">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t('common.tokenSearch')}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Escape' && setOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filtered.map((tok) => (
|
||||||
|
<div
|
||||||
|
key={tok.address}
|
||||||
|
className="tsel-item"
|
||||||
|
onClick={() => {
|
||||||
|
props.onChange(tok)
|
||||||
|
setOpen(false)
|
||||||
|
setQ('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{tok.symbol} {tok.native && <span className="dim">{t('common.gasToken')}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="dim mono-sm">{tok.native ? NATIVE.slice(0, 8) : shortAddr(tok.address)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <div className="tsel-item dim">{t('common.noMatch')}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useSyncExternalStore } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { EXPLORER } from '../config/addresses'
|
||||||
|
import { txlog, type LogLine } from '../lib/txlog'
|
||||||
|
|
||||||
|
function glyph(l: LogLine): string {
|
||||||
|
switch (l.kind) {
|
||||||
|
case 'ok':
|
||||||
|
return '✓'
|
||||||
|
case 'err':
|
||||||
|
return '✗'
|
||||||
|
case 'pending':
|
||||||
|
return '⧗'
|
||||||
|
default:
|
||||||
|
return '>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TxLogPanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const lines = useSyncExternalStore(txlog.subscribe, txlog.get)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
}, [lines])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="logpanel" ref={ref}>
|
||||||
|
{lines.length === 0 && <div className="logline dim">{t('log.ready')}</div>}
|
||||||
|
{lines.length > 2 && (
|
||||||
|
<div className="logline dim">
|
||||||
|
<span className="t">--------</span>
|
||||||
|
<span className="txt">{t('log.events', { n: lines.length })}</span>
|
||||||
|
<button className="chip logaction" onClick={() => txlog.clear()}>
|
||||||
|
{t('log.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lines.map((l) => (
|
||||||
|
<div key={l.id} className={`logline ${l.kind}`}>
|
||||||
|
<span className="t">{new Date(l.ts).toLocaleTimeString('en-GB')}</span>
|
||||||
|
<span className="txt">
|
||||||
|
{glyph(l)} {l.text}
|
||||||
|
</span>
|
||||||
|
{l.hash && (
|
||||||
|
<a href={`${EXPLORER}/tx/${l.hash}`} target="_blank" rel="noreferrer">
|
||||||
|
tx↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{l.action && (
|
||||||
|
<button className="chip logaction" onClick={l.action.onClick}>
|
||||||
|
{l.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
// ZAP panel — fund a position with ONE token. Shared by POOLS (mint/add) and
|
||||||
|
// POSITIONS (increase): the parent picks the target, this panel solves the
|
||||||
|
// split, previews it, and walks the tx sequence. See lib/zap.ts for the math.
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAccount } from 'wagmi'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { formatUnits, parseUnits, type Address } from 'viem'
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import { simulateClAdd, simulateV2Add, fmtApr } from '../lib/apr'
|
||||||
|
import { applySlippage } from '../lib/clmath'
|
||||||
|
import { fmtAmount, fmtUsd } from '../lib/format'
|
||||||
|
import { NATIVE } from '../lib/kyber'
|
||||||
|
import { txlog } from '../lib/txlog'
|
||||||
|
import { executeZap, planZap, zapStages, type ZapPlan, type ZapTarget } from '../lib/zap'
|
||||||
|
import { useBalances } from '../hooks/useBalances'
|
||||||
|
import type { PoolStat } from '../lib/poolstats'
|
||||||
|
import type { TokenInfo } from '../types'
|
||||||
|
import { Btn, NumInput } from './ui'
|
||||||
|
|
||||||
|
const ETH_GAS_BUFFER = parseUnits('0.001', 18)
|
||||||
|
|
||||||
|
export function ZapPanel(props: {
|
||||||
|
target: ZapTarget
|
||||||
|
t0: TokenInfo
|
||||||
|
t1: TokenInfo
|
||||||
|
stat?: PoolStat
|
||||||
|
upUsd?: number
|
||||||
|
wethUsd?: number | null
|
||||||
|
}) {
|
||||||
|
const { target, t0, t1 } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const pool = target.pool
|
||||||
|
const { address: user } = useAccount()
|
||||||
|
|
||||||
|
const hasWeth = [t0.address.toLowerCase(), t1.address.toLowerCase()].includes(ADDR.WETH.toLowerCase())
|
||||||
|
const wethIs0 = t0.address.toLowerCase() === ADDR.WETH.toLowerCase()
|
||||||
|
const [sel, setSel] = useState<'0' | '1' | 'eth'>(hasWeth ? (wethIs0 ? '0' : '1') : '0')
|
||||||
|
const [amtStr, setAmtStr] = useState('')
|
||||||
|
const [amount, setAmount] = useState(0n)
|
||||||
|
const [slip, setSlip] = useState(100)
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [runAt, setRunAt] = useState<{ i: number; failed: boolean } | null>(null)
|
||||||
|
const [runPlan, setRunPlan] = useState<ZapPlan | null>(null)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
const tokenInAddr: Address = sel === 'eth' ? NATIVE : sel === '0' ? t0.address : t1.address
|
||||||
|
const tIn: TokenInfo =
|
||||||
|
sel === 'eth' ? { address: NATIVE, symbol: 'ETH', decimals: 18, native: true } : sel === '0' ? t0 : t1
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
setAmount(parseUnits(amtStr === '' ? '0' : amtStr, tIn.decimals))
|
||||||
|
} catch {
|
||||||
|
setAmount(0n)
|
||||||
|
}
|
||||||
|
}, 350)
|
||||||
|
return () => clearTimeout(h)
|
||||||
|
}, [amtStr, tIn.decimals])
|
||||||
|
|
||||||
|
const bal = useBalances(user, [t0.address, t1.address, ...(hasWeth ? [NATIVE as Address] : [])])
|
||||||
|
const balIn = bal.data?.[tokenInAddr.toLowerCase()]
|
||||||
|
const spendable = balIn === undefined ? undefined : sel === 'eth' ? (balIn > ETH_GAS_BUFFER ? balIn - ETH_GAS_BUFFER : 0n) : balIn
|
||||||
|
const insufficient = spendable !== undefined && amount > spendable
|
||||||
|
|
||||||
|
// ticks key parts (cl targets re-plan when the parent's range changes)
|
||||||
|
const lo = target.kind === 'v2' ? 0 : target.tickLower
|
||||||
|
const hi = target.kind === 'v2' ? 0 : target.tickUpper
|
||||||
|
|
||||||
|
const plan = useQuery({
|
||||||
|
queryKey: ['zapPlan', pool.address, lo, hi, tokenInAddr, amount.toString()],
|
||||||
|
enabled: amount > 0n && !running,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 15_000,
|
||||||
|
retry: 1,
|
||||||
|
queryFn: ({ signal }) => planZap({ target, tokenIn: tokenInAddr, amountIn: amount, signal }),
|
||||||
|
})
|
||||||
|
const p = running ? runPlan : (plan.data ?? null)
|
||||||
|
const stages = useMemo(() => (p ? zapStages(p, target, t0, t1) : []), [p, target, t0, t1])
|
||||||
|
const tOut = p ? (p.inIs0 ? t1 : t0) : null
|
||||||
|
|
||||||
|
// projected APRs on the planned deposit (mint/add previews only — an
|
||||||
|
// increase's projection is the parent card's business)
|
||||||
|
const sim = useMemo(() => {
|
||||||
|
if (!p || target.kind === 'cl-increase') return null
|
||||||
|
const a0h = Number(formatUnits(p.dep0, t0.decimals))
|
||||||
|
const a1h = Number(formatUnits(p.dep1, t1.decimals))
|
||||||
|
if (target.kind === 'cl-mint')
|
||||||
|
return simulateClAdd({
|
||||||
|
pool: target.pool,
|
||||||
|
tickLower: target.tickLower,
|
||||||
|
tickUpper: target.tickUpper,
|
||||||
|
liquidity: p.liquidity,
|
||||||
|
amount0h: a0h,
|
||||||
|
amount1h: a1h,
|
||||||
|
dec0: t0.decimals,
|
||||||
|
dec1: t1.decimals,
|
||||||
|
stat: props.stat,
|
||||||
|
upUsd: props.upUsd,
|
||||||
|
wethUsd: props.wethUsd,
|
||||||
|
})
|
||||||
|
return simulateV2Add({
|
||||||
|
pool: target.pool,
|
||||||
|
amount0h: a0h,
|
||||||
|
amount1h: a1h,
|
||||||
|
dec0: t0.decimals,
|
||||||
|
dec1: t1.decimals,
|
||||||
|
stat: props.stat,
|
||||||
|
upUsd: props.upUsd,
|
||||||
|
})
|
||||||
|
}, [p, target, t0, t1, props.stat, props.upUsd, props.wethUsd])
|
||||||
|
|
||||||
|
// impact-vs-band honesty: a swap that eats a big slice of the band's width
|
||||||
|
// will land the deposit off-ratio (dust) or out of band entirely
|
||||||
|
const bandWarn = useMemo(() => {
|
||||||
|
if (!p || p.impactBps === null || target.kind === 'v2') return null
|
||||||
|
const halfPct = (Math.pow(1.0001, (target.tickUpper - target.tickLower) / 2) - 1) * 100
|
||||||
|
const impactPct = p.impactBps / 100
|
||||||
|
if (impactPct > Math.max(halfPct / 3, 2))
|
||||||
|
return t('zap.bandWarn', { impact: impactPct.toFixed(2), band: halfPct.toFixed(1) })
|
||||||
|
return null
|
||||||
|
}, [p, target, t])
|
||||||
|
|
||||||
|
const dustNote = (side: 0 | 1): string | null => {
|
||||||
|
if (!p) return null
|
||||||
|
const dust = side === 0 ? p.dust0 : p.dust1
|
||||||
|
const dep = side === 0 ? p.dep0 : p.dep1
|
||||||
|
if (dust === 0n || dep === 0n) return null
|
||||||
|
if (dust * 200n < dep) return null // <0.5% — noise
|
||||||
|
const t = side === 0 ? t0 : t1
|
||||||
|
return `${fmtAmount(dust, t.decimals)} ${t.symbol}`
|
||||||
|
}
|
||||||
|
const dusts = [dustNote(0), dustNote(1)].filter(Boolean)
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
if (!user || amount === 0n || running) return
|
||||||
|
setRunning(true)
|
||||||
|
setDone(false)
|
||||||
|
setRunAt({ i: 0, failed: false })
|
||||||
|
try {
|
||||||
|
// plan fresh for execution — the preview may be up to 30s old
|
||||||
|
let fresh: ZapPlan
|
||||||
|
try {
|
||||||
|
fresh = await planZap({ target, tokenIn: tokenInAddr, amountIn: amount })
|
||||||
|
} catch (e) {
|
||||||
|
txlog.push('err', t('zap.replanFailed', { err: (e as Error).message }))
|
||||||
|
setRunAt({ i: 0, failed: true })
|
||||||
|
setRunPlan(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRunPlan(fresh)
|
||||||
|
const res = await executeZap({
|
||||||
|
plan: fresh,
|
||||||
|
target,
|
||||||
|
user,
|
||||||
|
slipBps: slip,
|
||||||
|
t0,
|
||||||
|
t1,
|
||||||
|
onStage: (i) => setRunAt({ i, failed: false }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setRunAt(null)
|
||||||
|
setRunPlan(null)
|
||||||
|
setDone(true)
|
||||||
|
setAmtStr('')
|
||||||
|
} else {
|
||||||
|
setRunAt({ i: res.failedAt ?? 0, failed: true })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = runAt?.failed ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="zap">
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('zap.zapIn')}</span>
|
||||||
|
<button className={`chip ${sel === '0' ? 'on' : ''}`} onClick={() => setSel('0')} disabled={running}>
|
||||||
|
{t0.symbol}
|
||||||
|
</button>
|
||||||
|
<button className={`chip ${sel === '1' ? 'on' : ''}`} onClick={() => setSel('1')} disabled={running}>
|
||||||
|
{t1.symbol}
|
||||||
|
</button>
|
||||||
|
{hasWeth && (
|
||||||
|
<button
|
||||||
|
className={`chip ${sel === 'eth' ? 'on' : ''}`}
|
||||||
|
onClick={() => setSel('eth')}
|
||||||
|
disabled={running}
|
||||||
|
title={t('zap.ethTip')}
|
||||||
|
>
|
||||||
|
ETH
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<NumInput value={amtStr} onChange={setAmtStr} disabled={running} width={220} />
|
||||||
|
{spendable !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('common.bal')} {fmtAmount(spendable, tIn.decimals)}
|
||||||
|
</span>
|
||||||
|
<button className="chip" disabled={running} onClick={() => setAmtStr(formatUnits(spendable, tIn.decimals))}>
|
||||||
|
{t('common.max')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{insufficient && <span className="red mono-sm">{t('common.exceedsBalance')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('zap.slip')}</span>
|
||||||
|
{[50, 100, 300].map((b) => (
|
||||||
|
<button key={b} className={`chip ${slip === b ? 'on' : ''}`} onClick={() => setSlip(b)} disabled={running}>
|
||||||
|
{b / 100}%
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="dim mono-sm">{t('zap.slipHint')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{amount > 0n && plan.isLoading && (
|
||||||
|
<div className="dim mono-sm">
|
||||||
|
{t('zap.solving')}
|
||||||
|
<span className="spin">▮</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{amount > 0n && plan.isError && !running && (
|
||||||
|
<div className="red mono-sm">{t('zap.cantPlan', { err: (plan.error as Error).message })}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p && (
|
||||||
|
<div className="spec">
|
||||||
|
<div className="spec-hd">{t('zap.planTitle')}</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('zap.split')}</span>
|
||||||
|
<span className="sv">
|
||||||
|
{p.swapIn === 0n
|
||||||
|
? t('zap.keepAll', { amt: fmtAmount(p.keep, tIn.decimals), sym: p.inIs0 ? t0.symbol : t1.symbol })
|
||||||
|
: t('zap.keepSwap', {
|
||||||
|
keep: fmtAmount(p.keep, tIn.decimals),
|
||||||
|
swap: fmtAmount(p.swapIn, tIn.decimals),
|
||||||
|
sym: p.inIs0 ? t0.symbol : t1.symbol,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="sd">{p.swapIn === 0n ? t('zap.splitSdSingle') : t('zap.splitSd')}</span>
|
||||||
|
</div>
|
||||||
|
{p.swapIn > 0n && tOut && (
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('zap.swapRow')}</span>
|
||||||
|
<span className="sv">
|
||||||
|
→ ≈ {fmtAmount(p.estOut, tOut.decimals)} {tOut.symbol}
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('zap.swapMin', { amt: fmtAmount(applySlippage(p.estOut, slip), tOut.decimals), slip: slip / 100 })}
|
||||||
|
{p.impactBps !== null &&
|
||||||
|
(p.impactBps < -500 ? (
|
||||||
|
// a >5% "gain" means kyber's USD marks are off for this token
|
||||||
|
// (common for launchpad tokens) — the number isn't actionable
|
||||||
|
<span> · {t('zap.impactOff')}</span>
|
||||||
|
) : (
|
||||||
|
<span className={p.impactBps > 300 ? ' red' : p.impactBps > 150 ? ' amber' : ''}>
|
||||||
|
{' '}
|
||||||
|
· {t('zap.impact', { pct: (p.impactBps / 100).toFixed(2) })}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{p.routeLabel && <> · {t('zap.via', { route: p.routeLabel })}</>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('zap.depositRow')}</span>
|
||||||
|
<span className="sv">
|
||||||
|
≈ {fmtAmount(p.dep0, t0.decimals)} {t0.symbol} + {fmtAmount(p.dep1, t1.decimals)} {t1.symbol}
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{dusts.length > 0 ? t('zap.dustSd', { dust: dusts.join(' + ') }) : t('zap.actualSd')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sim && (
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('add.projected')}</span>
|
||||||
|
{sim.inRange ? (
|
||||||
|
<>
|
||||||
|
<span className="sv">
|
||||||
|
{t('add.projDep', { usd: fmtUsd(sim.depositUsd) })}
|
||||||
|
{Number.isFinite(sim.feeApr) && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· {t('add.projFeeApr')}
|
||||||
|
{pool.protocol === 'up33' ? <span className="dim"> {t('add.projIfUnstaked')}</span> : ''} ≈{' '}
|
||||||
|
{fmtApr(sim.feeApr)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{Number.isFinite(sim.emitApr) && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· {t('add.projEmitApr')}
|
||||||
|
<span className="dim"> {t('add.projIfStaked')}</span> ≈{' '}
|
||||||
|
<span className="green">{fmtApr(sim.emitApr)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('add.projShare', { pct: sim.sharePct < 0.01 ? '<0.01' : sim.sharePct.toFixed(2) })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="sv red">{t('add.projOut')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bandWarn && (
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">!!</span>
|
||||||
|
<span className="sv amber">{bandWarn}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stages.length > 0 && (
|
||||||
|
<div className="zap-steps mono-sm">
|
||||||
|
{stages.map((s, i) => {
|
||||||
|
const state = !runAt
|
||||||
|
? 'todo'
|
||||||
|
: i < runAt.i
|
||||||
|
? 'done'
|
||||||
|
: i === runAt.i
|
||||||
|
? failed
|
||||||
|
? 'fail'
|
||||||
|
: running
|
||||||
|
? 'run'
|
||||||
|
: 'todo'
|
||||||
|
: 'todo'
|
||||||
|
const mark = state === 'done' ? '✓' : state === 'run' ? '▮' : state === 'fail' ? '✗' : '·'
|
||||||
|
return (
|
||||||
|
<div key={i} className={`zstep ${state}`}>
|
||||||
|
<span className="zn">{i + 1}</span>
|
||||||
|
<span className="zm">{mark}</span>
|
||||||
|
<span>{s.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed && <div className="red mono-sm">{t('zap.halted', { n: (runAt?.i ?? 0) + 1 })}</div>}
|
||||||
|
{done && <div className="green mono-sm">{t('zap.done')}</div>}
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<Btn busy={running} onClick={run} disabled={!user || amount === 0n || !plan.data || insufficient || running}>
|
||||||
|
{!user
|
||||||
|
? t('common.connectWallet')
|
||||||
|
: stages.length > 0
|
||||||
|
? t('zap.runTx', { n: stages.length })
|
||||||
|
: t('zap.run')}
|
||||||
|
</Btn>
|
||||||
|
<span className="dim mono-sm">{t('zap.runHint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
// Visual lab (#lab) — components with SYNTHETIC data. Not linked in header.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ADDR, UNI } from '../../config/addresses'
|
||||||
|
import { getAmountsForLiquidity, getSqrtRatioAtTick } from '../../lib/clmath'
|
||||||
|
import type { ClPool, ClPosition, PoolsData, V2Pool, V2Position } from '../../types'
|
||||||
|
import { ProtoBadge } from '../ProtoBadge'
|
||||||
|
import { RangeBar } from '../RangeBar'
|
||||||
|
import { Badge, Btn } from '../ui'
|
||||||
|
import { AddCl, AddV2 } from './PoolsTab'
|
||||||
|
import { ClCard, IncreasePanel, V2Card } from './PositionsTab'
|
||||||
|
|
||||||
|
const LAB_POOL: ClPool = {
|
||||||
|
kind: 'cl',
|
||||||
|
protocol: 'up33',
|
||||||
|
address: '0x23D641FeCcD207E8794c593e8240444A0674C4Ba',
|
||||||
|
token0: ADDR.WETH,
|
||||||
|
token1: ADDR.UP,
|
||||||
|
gauge: null,
|
||||||
|
gaugeAlive: true,
|
||||||
|
weight: 0n,
|
||||||
|
rewardRate: 511000000000000000n, // ~0.51 UP/s
|
||||||
|
periodFinish: BigInt(Math.floor(Date.now() / 1000) + 5 * 86400),
|
||||||
|
tickSpacing: 200,
|
||||||
|
feePpm: 10_000,
|
||||||
|
unstakedFeePpm: 100_000,
|
||||||
|
sqrtPriceX96: getSqrtRatioAtTick(102458),
|
||||||
|
tick: 102458,
|
||||||
|
liquidity: 1_000_000_000_000_000_000_000n,
|
||||||
|
stakedLiquidity: 900_000_000_000_000_000_000n,
|
||||||
|
}
|
||||||
|
const LAB_DATA: PoolsData = {
|
||||||
|
pools: [LAB_POOL],
|
||||||
|
tokens: {
|
||||||
|
[ADDR.WETH.toLowerCase()]: { address: ADDR.WETH, symbol: 'WETH', decimals: 18 },
|
||||||
|
[ADDR.UP.toLowerCase()]: { address: ADDR.UP, symbol: 'UP', decimals: 18 },
|
||||||
|
},
|
||||||
|
protocol: {
|
||||||
|
weekly: 0n,
|
||||||
|
epochCount: 1,
|
||||||
|
activePeriod: 0,
|
||||||
|
totalWeight: 0n,
|
||||||
|
capMode: 2,
|
||||||
|
blockNumber: 0n,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAB_USER = '0x0000000000000000000000000000000000000001' as const
|
||||||
|
const labPos = (lower: number, upper: number, liquidity: bigint): ClPosition => ({
|
||||||
|
tokenId: 4242n,
|
||||||
|
pool: LAB_POOL,
|
||||||
|
tickLower: lower,
|
||||||
|
tickUpper: upper,
|
||||||
|
liquidity,
|
||||||
|
staked: false,
|
||||||
|
amount0: 0n,
|
||||||
|
amount1: 0n,
|
||||||
|
fees0: 0n,
|
||||||
|
fees1: 0n,
|
||||||
|
earned: 0n,
|
||||||
|
})
|
||||||
|
const labRun = async (fn: () => Promise<unknown>) => {
|
||||||
|
await fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Case(props: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">{props.title}</span>
|
||||||
|
<Badge tone="cyan">CL 1.00%</Badge>
|
||||||
|
<Badge>ts200</Badge>
|
||||||
|
<a className="dim mono-sm">#1234↗</a>
|
||||||
|
<Badge tone="green">STAKED</Badge>
|
||||||
|
<div className="card-actions">
|
||||||
|
<Btn>CLAIM UP</Btn>
|
||||||
|
<Btn tone="ghost">UNSTAKE</Btn>
|
||||||
|
<Btn tone="danger">WITHDRAW</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.children}
|
||||||
|
<div className="kv mono-sm">
|
||||||
|
<span>
|
||||||
|
<span className="k">holds</span>2.4501 WETH + 31,204 UP
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="k">pending UP</span>
|
||||||
|
<span className="green">1,204.5</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** self-driving RangeBar: a mean-reverting random walk stepping every 2s —
|
||||||
|
* exercises the glide, phosphor trail, near-edge amber and out-of-range
|
||||||
|
* alarm/excursion states without waiting for a real market to move */
|
||||||
|
function LiveRangeDemo() {
|
||||||
|
const [tick, setTick] = useState(101950)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(
|
||||||
|
() => setTick((t) => Math.round(t + (101950 - t) * 0.18 + (Math.random() - 0.5) * 1400)),
|
||||||
|
2000,
|
||||||
|
)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<RangeBar
|
||||||
|
tickLower={100000}
|
||||||
|
tickUpper={104000}
|
||||||
|
tick={tick}
|
||||||
|
sqrtPriceX96={getSqrtRatioAtTick(tick)}
|
||||||
|
dec0={18}
|
||||||
|
dec1={18}
|
||||||
|
sym0="WETH"
|
||||||
|
sym1="UP"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabTab() {
|
||||||
|
const mk = (tick: number) => getSqrtRatioAtTick(tick)
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900 }}>
|
||||||
|
<div className="section-title">COMPONENT LAB — SYNTHETIC DATA (not your positions)</div>
|
||||||
|
<Case title="LIVE DEMO · random walk — glide, trail, edge/out transitions">
|
||||||
|
<LiveRangeDemo />
|
||||||
|
</Case>
|
||||||
|
<Case title="WETH/UP · in range, centered">
|
||||||
|
<RangeBar tickLower={100000} tickUpper={104000} tick={101950} sqrtPriceX96={mk(101950)} dec0={18} dec1={18} sym0="WETH" sym1="UP" />
|
||||||
|
</Case>
|
||||||
|
<Case title="WETH/UP · near upper edge">
|
||||||
|
<RangeBar tickLower={100000} tickUpper={104000} tick={103790} sqrtPriceX96={mk(103790)} dec0={18} dec1={18} sym0="WETH" sym1="UP" />
|
||||||
|
</Case>
|
||||||
|
<Case title="WETH/UP · OUT below">
|
||||||
|
<RangeBar tickLower={100000} tickUpper={104000} tick={98800} sqrtPriceX96={mk(98800)} dec0={18} dec1={18} sym0="WETH" sym1="UP" />
|
||||||
|
</Case>
|
||||||
|
<Case title="WETH/USDG · 18/6 decimals, in range">
|
||||||
|
<RangeBar tickLower={-201800} tickUpper={-199800} tick={-200795} sqrtPriceX96={mk(-200795)} dec0={18} dec1={6} sym0="WETH" sym1="USDG" />
|
||||||
|
</Case>
|
||||||
|
<Case title="narrow band · OUT above">
|
||||||
|
<RangeBar tickLower={-201000} tickUpper={-200600} tick={-200380} sqrtPriceX96={mk(-200380)} dec0={18} dec1={6} sym0="WETH" sym1="USDG" />
|
||||||
|
</Case>
|
||||||
|
|
||||||
|
<div className="section-title">RANGE-ORDER BARS (order mode — POSITIONS view of LIMIT orders)</div>
|
||||||
|
<Case title="LIMIT sell UP → WETH · waiting (price below band)">
|
||||||
|
<RangeBar tickLower={101800} tickUpper={102200} tick={102437} sqrtPriceX96={mk(102437)} dec0={18} dec1={18} sym0="WETH" sym1="UP" order={{ fillFrac: 0, sellSym: 'UP', buySym: 'WETH' }} />
|
||||||
|
</Case>
|
||||||
|
<Case title="LIMIT sell UP → WETH · filling ~50%">
|
||||||
|
<RangeBar tickLower={101800} tickUpper={102200} tick={102000} sqrtPriceX96={mk(102000)} dec0={18} dec1={18} sym0="WETH" sym1="UP" order={{ fillFrac: 0.5025, sellSym: 'UP', buySym: 'WETH' }} />
|
||||||
|
</Case>
|
||||||
|
<Case title="LIMIT sell UP → WETH · fully filled">
|
||||||
|
<RangeBar tickLower={101800} tickUpper={102200} tick={101700} sqrtPriceX96={mk(101700)} dec0={18} dec1={18} sym0="WETH" sym1="UP" order={{ fillFrac: 1, sellSym: 'UP', buySym: 'WETH' }} />
|
||||||
|
</Case>
|
||||||
|
|
||||||
|
<div className="section-title">PROTOCOL BADGES (multi-DEX positions)</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">WETH/UP</span>
|
||||||
|
<ProtoBadge proto="up33" />
|
||||||
|
<Badge tone="cyan">CL 1.00%</Badge>
|
||||||
|
<Badge>ts200</Badge>
|
||||||
|
<Badge tone="green">STAKED</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">WETH/USDG</span>
|
||||||
|
<ProtoBadge proto="univ3" />
|
||||||
|
<Badge tone="cyan">CL 0.05%</Badge>
|
||||||
|
<Badge>ts10</Badge>
|
||||||
|
<Badge tone="amber">WALLET</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-title">POSITION CARDS — REDESIGN (value + earning-now lines)</div>
|
||||||
|
{(() => {
|
||||||
|
const gaugePool: ClPool = {
|
||||||
|
...LAB_POOL,
|
||||||
|
gauge: '0x00000000000000000000000000000000000000aa',
|
||||||
|
rewardRate: 5_000_000_000_000_000n, // 0.005 UP/s to the whole gauge
|
||||||
|
}
|
||||||
|
const stat = { vol24hUsd: 155_000, liqUsd: 184_000, source: 'dexscreener' as const }
|
||||||
|
const stakedIn: ClPosition = {
|
||||||
|
...labPos(100000, 104000, 500_000_000_000_000_000_000n),
|
||||||
|
pool: gaugePool,
|
||||||
|
staked: true,
|
||||||
|
earned: 1_204_500_000_000_000_000_000n,
|
||||||
|
...getAmountsForLiquidity(gaugePool.sqrtPriceX96, mk(100000), mk(104000), 500_000_000_000_000_000_000n),
|
||||||
|
}
|
||||||
|
const stakedOut: ClPosition = {
|
||||||
|
...labPos(103000, 104000, 300_000_000_000_000_000_000n),
|
||||||
|
tokenId: 4243n,
|
||||||
|
pool: gaugePool,
|
||||||
|
staked: true,
|
||||||
|
earned: 88_000_000_000_000_000_000n,
|
||||||
|
...getAmountsForLiquidity(gaugePool.sqrtPriceX96, mk(103000), mk(104000), 300_000_000_000_000_000_000n),
|
||||||
|
}
|
||||||
|
const uniWallet: ClPosition = {
|
||||||
|
...labPos(100000, 104000, 400_000_000_000_000_000_000n),
|
||||||
|
tokenId: 91_337n,
|
||||||
|
pool: { ...LAB_POOL, protocol: 'univ3', gauge: null, unstakedFeePpm: 0, feePpm: 3000, tickSpacing: 60 },
|
||||||
|
fees0: 12_400_000_000_000_000n,
|
||||||
|
fees1: 310_000_000_000_000_000_000n,
|
||||||
|
...getAmountsForLiquidity(LAB_POOL.sqrtPriceX96, mk(100000), mk(104000), 400_000_000_000_000_000_000n),
|
||||||
|
}
|
||||||
|
const v2Pos: V2Position = {
|
||||||
|
pool: {
|
||||||
|
...LAB_V2_PAIR,
|
||||||
|
protocol: 'up33',
|
||||||
|
gauge: '0x00000000000000000000000000000000000000bb',
|
||||||
|
gaugeAlive: true,
|
||||||
|
gaugeTotalSupply: 1_200_000_000_000_000_000_000n,
|
||||||
|
rewardRate: 2_000_000_000_000_000n,
|
||||||
|
periodFinish: BigInt(Math.floor(Date.now() / 1000) + 5 * 86400),
|
||||||
|
},
|
||||||
|
walletLp: 300_000_000_000_000_000_000n,
|
||||||
|
stakedLp: 500_000_000_000_000_000_000n,
|
||||||
|
earned: 812_000_000_000_000_000_000n,
|
||||||
|
claimable0: 21_000_000_000_000_000n,
|
||||||
|
claimable1: 590_000_000_000_000_000_000n,
|
||||||
|
amount0: 4_800_000_000_000_000_000n,
|
||||||
|
amount1: 135_360_000_000_000_000_000_000n,
|
||||||
|
}
|
||||||
|
const labUsd = { upUsd: 0.0688, wethUsd: 1928 }
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ClCard pos={stakedIn} data={LAB_DATA} xtokens={{}} user={LAB_USER} stat={stat} {...labUsd} />
|
||||||
|
<ClCard pos={stakedOut} data={LAB_DATA} xtokens={{}} user={LAB_USER} stat={stat} {...labUsd} />
|
||||||
|
<ClCard pos={uniWallet} data={LAB_DATA} xtokens={{}} user={LAB_USER} stat={{ ...stat, vol24hUsd: 351_000 }} {...labUsd} />
|
||||||
|
<V2Card pos={v2Pos} data={LAB_DATA} user={LAB_USER} stat={{ vol24hUsd: 82_000, liqUsd: 46_000, source: 'geckoterminal' }} {...labUsd} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="section-title">INCREASE PANEL LAB (synthetic positions)</div>
|
||||||
|
{(() => {
|
||||||
|
const inRange = labPos(100000, 104000, 500_000_000_000_000_000_000n)
|
||||||
|
const heldIn = getAmountsForLiquidity(LAB_POOL.sqrtPriceX96, mk(100000), mk(104000), inRange.liquidity)
|
||||||
|
const orderPos = labPos(103000, 103400, 800_000_000_000_000_000_000n) // above current tick -> token0-only
|
||||||
|
const heldOrder = getAmountsForLiquidity(LAB_POOL.sqrtPriceX96, mk(103000), mk(103400), orderPos.liquidity)
|
||||||
|
const uniPos: typeof inRange = {
|
||||||
|
...labPos(100000, 104000, 500_000_000_000_000_000_000n),
|
||||||
|
pool: { ...LAB_POOL, protocol: 'univ3', gauge: null },
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">WETH/UP · increase, in range</span>
|
||||||
|
<ProtoBadge proto="up33" />
|
||||||
|
</div>
|
||||||
|
<IncreasePanel
|
||||||
|
pos={inRange}
|
||||||
|
npm={ADDR.CL_PM}
|
||||||
|
t0sym="WETH"
|
||||||
|
t1sym="UP"
|
||||||
|
dec0={18}
|
||||||
|
dec1={18}
|
||||||
|
user={LAB_USER}
|
||||||
|
busy={false}
|
||||||
|
run={labRun}
|
||||||
|
sqrtP={LAB_POOL.sqrtPriceX96}
|
||||||
|
tick={LAB_POOL.tick}
|
||||||
|
held={heldIn}
|
||||||
|
isOrder={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">WETH/UP · increase a uniswap position</span>
|
||||||
|
<ProtoBadge proto="univ3" />
|
||||||
|
</div>
|
||||||
|
<IncreasePanel
|
||||||
|
pos={uniPos}
|
||||||
|
npm={UNI.V3_NPM}
|
||||||
|
t0sym="WETH"
|
||||||
|
t1sym="UP"
|
||||||
|
dec0={18}
|
||||||
|
dec1={18}
|
||||||
|
user={LAB_USER}
|
||||||
|
busy={false}
|
||||||
|
run={labRun}
|
||||||
|
sqrtP={LAB_POOL.sqrtPriceX96}
|
||||||
|
tick={LAB_POOL.tick}
|
||||||
|
held={heldIn}
|
||||||
|
isOrder={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">WETH/UP · increase a range order (price below band — token0 only)</span>
|
||||||
|
<ProtoBadge proto="up33" />
|
||||||
|
</div>
|
||||||
|
<IncreasePanel
|
||||||
|
pos={orderPos}
|
||||||
|
npm={ADDR.CL_PM}
|
||||||
|
t0sym="WETH"
|
||||||
|
t1sym="UP"
|
||||||
|
dec0={18}
|
||||||
|
dec1={18}
|
||||||
|
user={LAB_USER}
|
||||||
|
busy={false}
|
||||||
|
run={labRun}
|
||||||
|
sqrtP={LAB_POOL.sqrtPriceX96}
|
||||||
|
tick={LAB_POOL.tick}
|
||||||
|
held={heldOrder}
|
||||||
|
isOrder
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="section-title">ADD-LP PANEL LAB (synthetic WETH/UP pool)</div>
|
||||||
|
<div className="card">
|
||||||
|
<AddCl
|
||||||
|
pool={LAB_POOL}
|
||||||
|
data={LAB_DATA}
|
||||||
|
stat={{ vol24hUsd: 155_000, liqUsd: 184_000, source: 'dexscreener' }}
|
||||||
|
upUsd={0.0688}
|
||||||
|
wethUsd={1928}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="section-title">ADD-LP PANEL LAB — UNISWAP V3 (synthetic 0.3% pool, no gauge)</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">WETH/UP</span>
|
||||||
|
<ProtoBadge proto="univ3" />
|
||||||
|
</div>
|
||||||
|
<AddCl
|
||||||
|
pool={{ ...LAB_POOL, protocol: 'univ3', gauge: null, unstakedFeePpm: 0, feePpm: 3000, tickSpacing: 60 }}
|
||||||
|
data={LAB_DATA}
|
||||||
|
stat={{ vol24hUsd: 351_000, liqUsd: 363_000, source: 'dexscreener' }}
|
||||||
|
upUsd={0.0688}
|
||||||
|
wethUsd={1928}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="section-title">ADD-LP PANEL LAB — UNISWAP V2 (synthetic pair, reserves at pool price)</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span className="card-title">WETH/UP</span>
|
||||||
|
<ProtoBadge proto="univ2" />
|
||||||
|
</div>
|
||||||
|
<AddV2 pool={LAB_V2_PAIR} data={LAB_DATA} stat={{ vol24hUsd: 82_000, liqUsd: 46_000, source: 'geckoterminal' }} upUsd={0.0688} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vanilla univ2 pair: ~$46k TVL, reserves consistent with the CL lab price
|
||||||
|
// (tick 102458 ≈ 28.2k UP per WETH)
|
||||||
|
const LAB_V2_PAIR: V2Pool = {
|
||||||
|
kind: 'v2',
|
||||||
|
protocol: 'univ2',
|
||||||
|
address: '0x000000000000000000000000000000000000d0d0',
|
||||||
|
token0: ADDR.WETH,
|
||||||
|
token1: ADDR.UP,
|
||||||
|
stable: false,
|
||||||
|
reserve0: 12_000_000_000_000_000_000n, // 12 WETH
|
||||||
|
reserve1: 338_400_000_000_000_000_000_000n, // 338.4k UP
|
||||||
|
totalSupply: 2_000_000_000_000_000_000_000n,
|
||||||
|
gaugeTotalSupply: 0n,
|
||||||
|
feeBps: 30,
|
||||||
|
gauge: null,
|
||||||
|
gaugeAlive: false,
|
||||||
|
weight: 0n,
|
||||||
|
rewardRate: 0n,
|
||||||
|
periodFinish: 0n,
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
// LIMIT mode — "sell a token by deploying a one-sided LP" (CL range order).
|
||||||
|
// The band sits strictly beyond the current price on the side that fills as the
|
||||||
|
// sell token appreciates; a full traversal converts the deposit into the buy
|
||||||
|
// token at avg price √(pa·pb), plus swap fees earned in-band.
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAccount } from 'wagmi'
|
||||||
|
import { writeContract } from 'wagmi/actions'
|
||||||
|
import { formatUnits, parseUnits, type Address } from 'viem'
|
||||||
|
import { clPmAbi } from '../../abi'
|
||||||
|
import { ADDR, CHAIN_ID } from '../../config/addresses'
|
||||||
|
import { wagmiConfig } from '../../config/wagmi'
|
||||||
|
import {
|
||||||
|
alignTick,
|
||||||
|
applySlippage,
|
||||||
|
getAmountsForLiquidity,
|
||||||
|
getLiquidityForAmounts,
|
||||||
|
getSqrtRatioAtTick,
|
||||||
|
sqrtPriceToPrice,
|
||||||
|
tickDeltaForPct,
|
||||||
|
tickToPrice,
|
||||||
|
} from '../../lib/clmath'
|
||||||
|
import { clTokenUsd } from '../../lib/apr'
|
||||||
|
import { fmtAmount, fmtNum, fmtUsd } from '../../lib/format'
|
||||||
|
import { limitSideFor, mintedTokenId, tagLimit, type LimitSide } from '../../lib/limit'
|
||||||
|
import { deadline, ensureAllowance, fetchSqrtPriceX96, step } from '../../lib/tx'
|
||||||
|
import { txlog } from '../../lib/txlog'
|
||||||
|
import { usePools } from '../../hooks/usePools'
|
||||||
|
import { usePoolStats } from '../../hooks/usePoolStats'
|
||||||
|
import { useUpPrice } from '../../hooks/useUpPrice'
|
||||||
|
import { useBalances } from '../../hooks/useBalances'
|
||||||
|
import { useLiveSlot0 } from '../../hooks/useLiveSlot0'
|
||||||
|
import type { ClPool, TokenInfo } from '../../types'
|
||||||
|
import { Flash } from '../Flash'
|
||||||
|
import { RangeBar } from '../RangeBar'
|
||||||
|
import { TokenSelect } from '../TokenSelect'
|
||||||
|
import { Btn, NumInput } from '../ui'
|
||||||
|
|
||||||
|
const BANDS = [
|
||||||
|
{ id: 'b1', lo: 1, hi: 3 },
|
||||||
|
{ id: 'b2', lo: 2, hi: 5 },
|
||||||
|
{ id: 'b5', lo: 5, hi: 10 },
|
||||||
|
{ id: 'b10', lo: 10, hi: 25 },
|
||||||
|
]
|
||||||
|
const MIN_SELL_SLIP_BPS = 10 // out-of-range one-sided mint uses the full amount; 0.1% covers rounding
|
||||||
|
|
||||||
|
/** narrowest possible band hugging the market: one tick-spacing right beyond
|
||||||
|
* current price — the maker version of "sell at the touch" */
|
||||||
|
function tightTicks(pool: ClPool, side: LimitSide): { lower: number; upper: number } {
|
||||||
|
const s = pool.tickSpacing
|
||||||
|
if (side === 'sell0') {
|
||||||
|
let lower = alignTick(pool.tick, s, 'ceil')
|
||||||
|
while (getSqrtRatioAtTick(lower) <= pool.sqrtPriceX96) lower += s
|
||||||
|
return { lower, upper: lower + s }
|
||||||
|
}
|
||||||
|
let upper = alignTick(pool.tick, s, 'floor')
|
||||||
|
while (getSqrtRatioAtTick(upper) >= pool.sqrtPriceX96) upper -= s
|
||||||
|
return { lower: upper - s, upper }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** premium band (% above market for the SELL token) -> snapped ticks, strictly out of range */
|
||||||
|
function bandTicks(
|
||||||
|
pool: ClPool,
|
||||||
|
side: LimitSide,
|
||||||
|
loPct: number,
|
||||||
|
hiPct: number,
|
||||||
|
): { lower: number; upper: number } | null {
|
||||||
|
if (!(loPct > 0) || !(hiPct > loPct)) return null
|
||||||
|
const s = pool.tickSpacing
|
||||||
|
if (side === 'sell0') {
|
||||||
|
// sell token0: fills as token1/token0 price rises — band above
|
||||||
|
let lower = alignTick(pool.tick + tickDeltaForPct(loPct / 100), s, 'ceil')
|
||||||
|
while (getSqrtRatioAtTick(lower) <= pool.sqrtPriceX96) lower += s
|
||||||
|
let upper = alignTick(pool.tick + tickDeltaForPct(hiPct / 100), s, 'ceil')
|
||||||
|
if (upper <= lower) upper = lower + s
|
||||||
|
return { lower, upper }
|
||||||
|
}
|
||||||
|
// sell token1: fills as token1/token0 price falls — band below
|
||||||
|
let upper = alignTick(pool.tick - tickDeltaForPct(loPct / 100), s, 'floor')
|
||||||
|
while (getSqrtRatioAtTick(upper) >= pool.sqrtPriceX96) upper -= s
|
||||||
|
let lower = alignTick(pool.tick - tickDeltaForPct(hiPct / 100), s, 'floor')
|
||||||
|
if (lower >= upper) lower = upper - s
|
||||||
|
return { lower, upper }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LimitPanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { address: user } = useAccount()
|
||||||
|
const pools = usePools()
|
||||||
|
const data = pools.data
|
||||||
|
|
||||||
|
const [sellAddr, setSellAddr] = useState<Address | null>(null)
|
||||||
|
const [buyAddr, setBuyAddr] = useState<Address | null>(null)
|
||||||
|
const [poolAddr, setPoolAddr] = useState<Address | null>(null)
|
||||||
|
const [amtStr, setAmtStr] = useState('')
|
||||||
|
const [bandId, setBandId] = useState('tight')
|
||||||
|
const [loStr, setLoStr] = useState('3')
|
||||||
|
const [hiStr, setHiStr] = useState('8')
|
||||||
|
const [explain, setExplain] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const clPools = useMemo(
|
||||||
|
() => (data?.pools.filter((p) => p.kind === 'cl' && p.sqrtPriceX96 > 0n) ?? []) as ClPool[],
|
||||||
|
[data],
|
||||||
|
)
|
||||||
|
const sellList = useMemo(() => {
|
||||||
|
const seen = new Map<string, TokenInfo>()
|
||||||
|
for (const p of clPools) {
|
||||||
|
for (const a of [p.token0, p.token1]) {
|
||||||
|
const t = data?.tokens[a.toLowerCase()]
|
||||||
|
if (t && !seen.has(a.toLowerCase())) seen.set(a.toLowerCase(), t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()]
|
||||||
|
}, [clPools, data])
|
||||||
|
|
||||||
|
const buyList = useMemo(() => {
|
||||||
|
if (!sellAddr) return []
|
||||||
|
const s = sellAddr.toLowerCase()
|
||||||
|
const seen = new Map<string, TokenInfo>()
|
||||||
|
for (const p of clPools) {
|
||||||
|
const other =
|
||||||
|
p.token0.toLowerCase() === s ? p.token1 : p.token1.toLowerCase() === s ? p.token0 : null
|
||||||
|
if (other) {
|
||||||
|
const t = data?.tokens[other.toLowerCase()]
|
||||||
|
if (t && !seen.has(other.toLowerCase())) seen.set(other.toLowerCase(), t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()]
|
||||||
|
}, [clPools, data, sellAddr])
|
||||||
|
|
||||||
|
const sell = sellAddr ? (data?.tokens[sellAddr.toLowerCase()] ?? null) : null
|
||||||
|
const buy = buyAddr ? (data?.tokens[buyAddr.toLowerCase()] ?? null) : null
|
||||||
|
|
||||||
|
// defaults: sell UP → WETH (the "exit farm rewards" case)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sellAddr && sellList.length) {
|
||||||
|
const up = sellList.find((t) => t.address.toLowerCase() === ADDR.UP.toLowerCase())
|
||||||
|
setSellAddr((up ?? sellList[0]).address)
|
||||||
|
}
|
||||||
|
}, [sellList, sellAddr])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!buyList.length) return
|
||||||
|
if (!buyAddr || !buyList.some((t) => t.address.toLowerCase() === buyAddr.toLowerCase())) {
|
||||||
|
const weth = buyList.find((t) => t.address.toLowerCase() === ADDR.WETH.toLowerCase())
|
||||||
|
setBuyAddr((weth ?? buyList[0]).address)
|
||||||
|
}
|
||||||
|
}, [buyList, buyAddr])
|
||||||
|
|
||||||
|
const pairPools = useMemo(() => {
|
||||||
|
if (!sellAddr || !buyAddr) return []
|
||||||
|
const a = sellAddr.toLowerCase()
|
||||||
|
const b = buyAddr.toLowerCase()
|
||||||
|
return clPools
|
||||||
|
.filter((p) => {
|
||||||
|
const t0 = p.token0.toLowerCase()
|
||||||
|
const t1 = p.token1.toLowerCase()
|
||||||
|
return (t0 === a && t1 === b) || (t0 === b && t1 === a)
|
||||||
|
})
|
||||||
|
.sort((x, y) => (y.liquidity > x.liquidity ? 1 : y.liquidity < x.liquidity ? -1 : 0))
|
||||||
|
}, [clPools, sellAddr, buyAddr])
|
||||||
|
const poolBase = pairPools.find((p) => p.address === poolAddr) ?? pairPools[0] ?? null
|
||||||
|
|
||||||
|
// fast slot0 feed for the selected pool: band + market price track live
|
||||||
|
const liveQ = useLiveSlot0(poolBase ? [poolBase.address] : [])
|
||||||
|
const pool = useMemo(() => {
|
||||||
|
if (!poolBase) return null
|
||||||
|
const l = liveQ.data?.[poolBase.address.toLowerCase()]
|
||||||
|
return l ? { ...poolBase, tick: l.tick, sqrtPriceX96: l.sqrtPriceX96 } : poolBase
|
||||||
|
}, [poolBase, liveQ.data])
|
||||||
|
|
||||||
|
const bal = useBalances(user, sellAddr ? [sellAddr] : [])
|
||||||
|
const balSell = sellAddr ? bal.data?.[sellAddr.toLowerCase()] : undefined
|
||||||
|
const upPrice = useUpPrice()
|
||||||
|
const stats = usePoolStats()
|
||||||
|
|
||||||
|
const t0 = pool ? (data?.tokens[pool.token0.toLowerCase()] ?? null) : null
|
||||||
|
const t1 = pool ? (data?.tokens[pool.token1.toLowerCase()] ?? null) : null
|
||||||
|
const side: LimitSide | null = pool && sellAddr ? limitSideFor(pool, sellAddr) : null
|
||||||
|
|
||||||
|
// USD context via USDG/WETH/UP anchors (display only)
|
||||||
|
const usd = useMemo(() => {
|
||||||
|
if (!pool || !t0 || !t1 || !side) return null
|
||||||
|
const px = clTokenUsd(pool, t0.decimals, t1.decimals, upPrice.data, stats.data?.wethUsd)
|
||||||
|
if (!px) return null
|
||||||
|
return { sell: side === 'sell0' ? px.p0 : px.p1, buy: side === 'sell0' ? px.p1 : px.p0 }
|
||||||
|
}, [pool, t0, t1, side, upPrice.data, stats.data?.wethUsd])
|
||||||
|
|
||||||
|
const preset = BANDS.find((b) => b.id === bandId)
|
||||||
|
const loPct = preset ? preset.lo : Number(loStr)
|
||||||
|
const hiPct = preset ? preset.hi : Number(hiStr)
|
||||||
|
const ticks =
|
||||||
|
pool && side ? (bandId === 'tight' ? tightTicks(pool, side) : bandTicks(pool, side, loPct, hiPct)) : null
|
||||||
|
// does a preset/custom band collapse onto the tight band? (sub-spacing inputs snap)
|
||||||
|
const tight = pool && side ? tightTicks(pool, side) : null
|
||||||
|
const isTightBand = !!(ticks && tight && ticks.lower === tight.lower && ticks.upper === tight.upper)
|
||||||
|
|
||||||
|
const amt = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return sell ? parseUnits(amtStr === '' ? '0' : amtStr, sell.decimals) : 0n
|
||||||
|
} catch {
|
||||||
|
return 0n
|
||||||
|
}
|
||||||
|
}, [amtStr, sell])
|
||||||
|
const insufficient = balSell !== undefined && amt > balSell
|
||||||
|
|
||||||
|
// band premiums after tick snapping + full-fill projection
|
||||||
|
const calc = useMemo(() => {
|
||||||
|
if (!pool || !side || !ticks || !t0 || !t1 || !sell || !buy) return null
|
||||||
|
const spot01 = sqrtPriceToPrice(pool.sqrtPriceX96, t0.decimals, t1.decimals)
|
||||||
|
if (!Number.isFinite(spot01) || spot01 <= 0) return null
|
||||||
|
const px = (t: number) => tickToPrice(t, t0.decimals, t1.decimals)
|
||||||
|
const spotSell = side === 'sell0' ? spot01 : 1 / spot01
|
||||||
|
const premLo = side === 'sell0' ? px(ticks.lower) / spot01 - 1 : spot01 / px(ticks.upper) - 1
|
||||||
|
const premHi = side === 'sell0' ? px(ticks.upper) / spot01 - 1 : spot01 / px(ticks.lower) - 1
|
||||||
|
const sqrtA = getSqrtRatioAtTick(ticks.lower)
|
||||||
|
const sqrtB = getSqrtRatioAtTick(ticks.upper)
|
||||||
|
// avg fill premium = geometric mean of the band (amount-independent)
|
||||||
|
const avgPrem = Math.sqrt((1 + premLo) * (1 + premHi)) - 1
|
||||||
|
let proceeds: bigint | null = null
|
||||||
|
let avgPx: number | null = null
|
||||||
|
let feeShare: number | null = null
|
||||||
|
if (amt > 0n) {
|
||||||
|
const L =
|
||||||
|
side === 'sell0'
|
||||||
|
? getLiquidityForAmounts(pool.sqrtPriceX96, sqrtA, sqrtB, amt, 0n)
|
||||||
|
: getLiquidityForAmounts(pool.sqrtPriceX96, sqrtA, sqrtB, 0n, amt)
|
||||||
|
if (L > 0n) {
|
||||||
|
proceeds =
|
||||||
|
side === 'sell0'
|
||||||
|
? getAmountsForLiquidity(sqrtB, sqrtA, sqrtB, L).amount1
|
||||||
|
: getAmountsForLiquidity(sqrtA, sqrtA, sqrtB, L).amount0
|
||||||
|
const amtH = Number(formatUnits(amt, sell.decimals))
|
||||||
|
const outH = Number(formatUnits(proceeds, buy.decimals))
|
||||||
|
if (amtH > 0) avgPx = outH / amtH
|
||||||
|
// your share of active liquidity while price sits inside your band
|
||||||
|
feeShare = Number(L) / (Number(pool.liquidity) + Number(L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { spotSell, premLo, premHi, avgPrem, sqrtA, sqrtB, proceeds, avgPx, feeShare }
|
||||||
|
}, [pool, side, ticks?.lower, ticks?.upper, t0, t1, sell, buy, amt])
|
||||||
|
|
||||||
|
const setPct = (pct: number) => {
|
||||||
|
if (balSell !== undefined && sell) setAmtStr(formatUnits((balSell * BigInt(pct)) / 100n, sell.decimals))
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = async () => {
|
||||||
|
if (!user || !pool || !side || !ticks || !sell || !buy || amt === 0n || !calc) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (!(await ensureAllowance(sell.address, user, ADDR.CL_PM, amt, sell.symbol))) return
|
||||||
|
// re-check with a fresh price: the band must still be strictly out of range
|
||||||
|
const fresh = await fetchSqrtPriceX96(pool.address)
|
||||||
|
const stillOut = side === 'sell0' ? fresh < calc.sqrtA : fresh > calc.sqrtB
|
||||||
|
if (!stillOut) {
|
||||||
|
txlog.push('err', t('limit.bandEntered'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const minSell = applySlippage(amt, MIN_SELL_SLIP_BPS)
|
||||||
|
await step(
|
||||||
|
t('limit.stPlace', { amt: amtStr, sell: sell.symbol, buy: buy.symbol }),
|
||||||
|
() =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: clPmAbi,
|
||||||
|
address: ADDR.CL_PM,
|
||||||
|
functionName: 'mint',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
token0: pool.token0,
|
||||||
|
token1: pool.token1,
|
||||||
|
tickSpacing: pool.tickSpacing,
|
||||||
|
tickLower: ticks.lower,
|
||||||
|
tickUpper: ticks.upper,
|
||||||
|
amount0Desired: side === 'sell0' ? amt : 0n,
|
||||||
|
amount1Desired: side === 'sell1' ? amt : 0n,
|
||||||
|
amount0Min: side === 'sell0' ? minSell : 0n,
|
||||||
|
amount1Min: side === 'sell1' ? minSell : 0n,
|
||||||
|
recipient: user,
|
||||||
|
deadline: deadline(),
|
||||||
|
sqrtPriceX96: 0n,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onSuccess: (rcpt) => {
|
||||||
|
const id = mintedTokenId(rcpt, user)
|
||||||
|
if (id === null) return
|
||||||
|
tagLimit(id, {
|
||||||
|
sell: sell.address,
|
||||||
|
buy: buy.address,
|
||||||
|
sellSym: sell.symbol,
|
||||||
|
buySym: buy.symbol,
|
||||||
|
amountIn: amt.toString(),
|
||||||
|
pool: pool.address,
|
||||||
|
ts: Date.now(),
|
||||||
|
})
|
||||||
|
txlog.push('info', t('limit.orderLive', { id: id.toString(), sym: sell.symbol }), rcpt.transactionHash, {
|
||||||
|
label: t('limit.track'),
|
||||||
|
onClick: () => {
|
||||||
|
location.hash = 'positions'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
setAmtStr('')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pools.isLoading || !data)
|
||||||
|
return (
|
||||||
|
<div className="dim">
|
||||||
|
{t('limit.loadingPools')}
|
||||||
|
<span className="spin">▮</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (!sell) return <div className="dim">{t('limit.noPools')}</div>
|
||||||
|
|
||||||
|
const amtH = sell && amt > 0n ? Number(formatUnits(amt, sell.decimals)) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="dim mono-sm" style={{ marginBottom: 8 }}>
|
||||||
|
{t('limit.intro')}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('limit.sell')}</span>
|
||||||
|
<TokenSelect list={sellList} value={sell} exclude={buy?.address} onChange={(tok) => setSellAddr(tok.address)} />
|
||||||
|
<NumInput value={amtStr} onChange={setAmtStr} width={180} />
|
||||||
|
{[25, 50, 75].map((p) => (
|
||||||
|
<button key={p} className="chip" onClick={() => setPct(p)}>
|
||||||
|
{p}%
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="chip" onClick={() => setPct(100)}>
|
||||||
|
{t('common.max')}
|
||||||
|
</button>
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('common.bal')} {balSell !== undefined ? fmtAmount(balSell, sell.decimals) : '—'}
|
||||||
|
{usd && amtH > 0 && <span> · ≈ {fmtUsd(amtH * usd.sell)}</span>}
|
||||||
|
{insufficient && <span className="red"> {t('common.insufficient')}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('limit.for')}</span>
|
||||||
|
{buy ? (
|
||||||
|
<TokenSelect list={buyList} value={buy} exclude={sell.address} onChange={(tok) => setBuyAddr(tok.address)} />
|
||||||
|
) : (
|
||||||
|
<span className="dim mono-sm">{t('limit.noPairs', { sym: sell.symbol })}</span>
|
||||||
|
)}
|
||||||
|
{pool && buy && (
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('limit.market', { sell: sell.symbol })}{' '}
|
||||||
|
<Flash v={calc?.spotSell} arrow>
|
||||||
|
{calc ? fmtNum(calc.spotSell) : '—'} {buy.symbol}
|
||||||
|
</Flash>{' '}
|
||||||
|
· 1 {buy.symbol} = {calc ? fmtNum(1 / calc.spotSell) : '—'} {sell.symbol}
|
||||||
|
{pairPools.length === 1 && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· {t('limit.poolChip', { ts: pool.tickSpacing, fee: (pool.feePpm / 10_000).toFixed(2) })}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pairPools.length > 1 && (
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('limit.pool')}</span>
|
||||||
|
{pairPools.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.address}
|
||||||
|
className={`chip ${p.address === pool?.address ? 'on' : ''}`}
|
||||||
|
onClick={() => setPoolAddr(p.address)}
|
||||||
|
>
|
||||||
|
ts{p.tickSpacing} · {(p.feePpm / 10_000).toFixed(2)}%
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!pool && buy && <div className="dim mono-sm">{t('limit.noPoolFor', { pair: `${sell.symbol}/${buy.symbol}` })}</div>}
|
||||||
|
|
||||||
|
{pool && buy && (
|
||||||
|
<>
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('limit.sellBand')}</span>
|
||||||
|
<button className={`chip ${explain ? 'on' : ''}`} onClick={() => setExplain(!explain)} title={t('limit.explainTip')}>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`chip ${bandId === 'tight' ? 'on' : ''}`}
|
||||||
|
onClick={() => setBandId('tight')}
|
||||||
|
title={t('limit.tightTip')}
|
||||||
|
>
|
||||||
|
{t('limit.tight')}
|
||||||
|
</button>
|
||||||
|
{BANDS.map((b) => (
|
||||||
|
<button key={b.id} className={`chip ${bandId === b.id ? 'on' : ''}`} onClick={() => setBandId(b.id)}>
|
||||||
|
+{b.lo}→{b.hi}%
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className={`chip ${bandId === 'custom' ? 'on' : ''}`} onClick={() => setBandId('custom')}>
|
||||||
|
{t('limit.custom')}
|
||||||
|
</button>
|
||||||
|
{bandId === 'custom' && (
|
||||||
|
<>
|
||||||
|
<span className="dim mono-sm">+</span>
|
||||||
|
<NumInput value={loStr} onChange={setLoStr} width={64} />
|
||||||
|
<span className="dim mono-sm">% → +</span>
|
||||||
|
<NumInput value={hiStr} onChange={setHiStr} width={64} />
|
||||||
|
<span className="dim mono-sm">{t('limit.aboveMarket')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{explain && (
|
||||||
|
<div className="spec">
|
||||||
|
<div className="spec-hd">{t('limit.exTitle')}</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.exStart')}</span>
|
||||||
|
<span className="sv green">{calc ? `+${(calc.premLo * 100).toFixed(1)}%` : '+x%'}</span>
|
||||||
|
<span className="sd">{t('limit.exStartSd', { sym: sell.symbol })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.exEnd')}</span>
|
||||||
|
<span className="sv green">{calc ? `+${(calc.premHi * 100).toFixed(1)}%` : '+y%'}</span>
|
||||||
|
<span className="sd">{t('limit.exEndSd', { sym: sell.symbol })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.exAvg')}</span>
|
||||||
|
<span className="sv green">{calc ? `+${(calc.avgPrem * 100).toFixed(1)}%` : '√(s·e)'}</span>
|
||||||
|
<span className="sd">{t('limit.exAvgSd')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.exGrid')}</span>
|
||||||
|
<span className="sv">ts{pool.tickSpacing}</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('limit.exGridSd', { ts: pool.tickSpacing, pct: (pool.tickSpacing / 100).toFixed(1) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticks && calc ? (
|
||||||
|
<div className="spec">
|
||||||
|
<div className="spec-hd">{t('limit.orderTitle')}</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.fillStarts')}</span>
|
||||||
|
<span className="sv green">
|
||||||
|
<Flash v={calc.premLo}>+{(calc.premLo * 100).toFixed(1)}%</Flash>
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('limit.priceEq', {
|
||||||
|
sell: sell.symbol,
|
||||||
|
px: fmtNum(calc.spotSell * (1 + calc.premLo)),
|
||||||
|
buy: buy.symbol,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.fullySold')}</span>
|
||||||
|
<span className="sv green">
|
||||||
|
<Flash v={calc.premHi}>+{(calc.premHi * 100).toFixed(1)}%</Flash>
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('limit.priceEq', {
|
||||||
|
sell: sell.symbol,
|
||||||
|
px: fmtNum(calc.spotSell * (1 + calc.premHi)),
|
||||||
|
buy: buy.symbol,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.avgFill')}</span>
|
||||||
|
<span className="sv green">+{(calc.avgPrem * 100).toFixed(1)}%</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('limit.priceApprox', {
|
||||||
|
sell: sell.symbol,
|
||||||
|
px: fmtNum(calc.spotSell * (1 + calc.avgPrem)),
|
||||||
|
buy: buy.symbol,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.band')}</span>
|
||||||
|
<span className="sv">
|
||||||
|
[{ticks.lower} → {ticks.upper}]
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('limit.bandSd', { ts: pool.tickSpacing, pct: (pool.tickSpacing / 100).toFixed(1) })}
|
||||||
|
{bandId === 'tight' ? (
|
||||||
|
<> · {t('limit.bandTight')}</>
|
||||||
|
) : isTightBand ? (
|
||||||
|
<span className="cyan"> · {t('limit.bandSnaps')}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{t0 && t1 && (
|
||||||
|
<div style={{ padding: '0 10px' }}>
|
||||||
|
<RangeBar
|
||||||
|
tickLower={ticks.lower}
|
||||||
|
tickUpper={ticks.upper}
|
||||||
|
tick={pool.tick}
|
||||||
|
sqrtPriceX96={pool.sqrtPriceX96}
|
||||||
|
dec0={t0.decimals}
|
||||||
|
dec1={t1.decimals}
|
||||||
|
sym0={t0.symbol}
|
||||||
|
sym1={t1.symbol}
|
||||||
|
order={{ fillFrac: 0, sellSym: sell.symbol, buySym: buy.symbol }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{calc.avgPx != null && calc.proceeds != null && (
|
||||||
|
<>
|
||||||
|
<div className="spec-hd">{t('limit.projTitle')}</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.projSell')}</span>
|
||||||
|
<span className="sv">
|
||||||
|
{amtStr} {sell.symbol}
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{t('limit.projSellSd', { px: fmtNum(calc.avgPx), buy: buy.symbol })}{' '}
|
||||||
|
<span className="green">
|
||||||
|
{t('limit.projVsMarket', { pct: ((calc.avgPx / calc.spotSell - 1) * 100).toFixed(1) })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.projReceive')}</span>
|
||||||
|
<span className="sv green">
|
||||||
|
{fmtAmount(calc.proceeds, buy.decimals)} {buy.symbol}
|
||||||
|
</span>
|
||||||
|
<span className="sd">
|
||||||
|
{usd && <>≈ {fmtUsd(Number(formatUnits(calc.proceeds, buy.decimals)) * usd.buy)} · </>}
|
||||||
|
{t('limit.projWhenFilled')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const stat = stats.data?.byPool[pool.address.toLowerCase()]
|
||||||
|
return calc.feeShare != null && stat?.vol24hUsd != null ? (
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.projFeeIncome')}</span>
|
||||||
|
<span className="sv green">
|
||||||
|
≈ {fmtUsd(stat.vol24hUsd * (pool.feePpm / 1e6) * (1 - pool.unstakedFeePpm / 1e6) * calc.feeShare)}
|
||||||
|
/d
|
||||||
|
</span>
|
||||||
|
<span className="sd">{t('limit.projFeeIncomeSd', { share: (calc.feeShare * 100).toFixed(1) })}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.projFeeIncome')}</span>
|
||||||
|
<span className="sv green">{(pool.feePpm / 10_000).toFixed(2)}%</span>
|
||||||
|
<span className="sd">{t('limit.projFeePct')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="spec-hd">{t('limit.feesTitle')}</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.thisOrder')}</span>
|
||||||
|
<span className="sv green">0%</span>
|
||||||
|
<span className="sd">{t('limit.makerSd')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.marketSwap')}</span>
|
||||||
|
<span className="sv red">{(pool.feePpm / 10_000).toFixed(2)}%</span>
|
||||||
|
<span className="sd">
|
||||||
|
{amt > 0n && usd
|
||||||
|
? t('limit.takerLoss', { usd: fmtUsd(amtH * usd.sell * (pool.feePpm / 1e6)) })
|
||||||
|
: t('limit.takerSd')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-hd">{t('limit.mechTitle')}</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.mechFills')}</span>
|
||||||
|
<span className="sv green">{sell.symbol} ↑</span>
|
||||||
|
<span className="sd">{t('limit.mechFillsSd', { buy: buy.symbol })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.mechUnfills')}</span>
|
||||||
|
<span className="sv amber">{sell.symbol} ↓</span>
|
||||||
|
<span className="sd">{t('limit.mechUnfillsSd')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.mechAfter')}</span>
|
||||||
|
<span className="sv">{t('limit.mechWithdraw')}</span>
|
||||||
|
<span className="sd">
|
||||||
|
<a href="#positions">{t('hdr.positions')}</a> {t('limit.mechAfterSd')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="spec-row">
|
||||||
|
<span className="sk">{t('limit.mechStaking')}</span>
|
||||||
|
<span className="sv amber">{t('limit.mechDont')}</span>
|
||||||
|
<span className="sd">{t('limit.mechStakingSd')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dim mono-sm">{t('limit.invalidBand')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-row" style={{ marginTop: 8 }}>
|
||||||
|
<Btn
|
||||||
|
big
|
||||||
|
busy={busy}
|
||||||
|
disabled={!user || !ticks || amt === 0n || insufficient || !calc}
|
||||||
|
onClick={place}
|
||||||
|
>
|
||||||
|
{!user ? t('common.connectWallet') : insufficient ? t('common.insufficientBalance') : t('limit.place')}
|
||||||
|
</Btn>
|
||||||
|
{user && ticks && calc && amt > 0n && <span className="dim mono-sm">{t('limit.placeHint')}</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,991 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import { useAccount } from 'wagmi'
|
||||||
|
import { readContract, writeContract } from 'wagmi/actions'
|
||||||
|
import { formatUnits, parseUnits } from 'viem'
|
||||||
|
import { clPmAbi, uniV2RouterAbi, uniV3PmAbi, v2RouterAbi } from '../../abi'
|
||||||
|
import { ADDR, CHAIN_ID, EXPLORER, UNI, WEEK } from '../../config/addresses'
|
||||||
|
import { wagmiConfig } from '../../config/wagmi'
|
||||||
|
import {
|
||||||
|
alignTick,
|
||||||
|
applySlippage,
|
||||||
|
fullRangeTicks,
|
||||||
|
getLiquidityForAmounts,
|
||||||
|
getSqrtRatioAtTick,
|
||||||
|
minAmountsForLiquidity,
|
||||||
|
previewDeposit,
|
||||||
|
priceToTick,
|
||||||
|
sqrtPriceToPrice,
|
||||||
|
tickDeltaForPct,
|
||||||
|
tickToPrice,
|
||||||
|
} from '../../lib/clmath'
|
||||||
|
import {
|
||||||
|
emitAprOf,
|
||||||
|
feeAprOf,
|
||||||
|
fees24Of,
|
||||||
|
fmtApr,
|
||||||
|
simulateClAdd,
|
||||||
|
simulateV2Add,
|
||||||
|
stakedShareOf,
|
||||||
|
type AddSim,
|
||||||
|
} from '../../lib/apr'
|
||||||
|
import { fmtAmount, fmtCompactAmount, fmtNum, fmtUsd, nowSec } from '../../lib/format'
|
||||||
|
import { deadline, ensureAllowance, fetchSqrtPriceX96, step } from '../../lib/tx'
|
||||||
|
import { useBalances } from '../../hooks/useBalances'
|
||||||
|
import { usePositions } from '../../hooks/usePositions'
|
||||||
|
import { usePoolStats } from '../../hooks/usePoolStats'
|
||||||
|
import { useUpPrice } from '../../hooks/useUpPrice'
|
||||||
|
import type { PoolStat } from '../../lib/poolstats'
|
||||||
|
import { poolTypeLabel, tokenOf, usePools } from '../../hooks/usePools'
|
||||||
|
import { useUniPools } from '../../hooks/useUniPools'
|
||||||
|
import type { ClPool, Pool, PoolsData, V2Pool } from '../../types'
|
||||||
|
import { Flash } from '../Flash'
|
||||||
|
import { ProtoBadge } from '../ProtoBadge'
|
||||||
|
import { RangeBar } from '../RangeBar'
|
||||||
|
import { ZapPanel } from '../ZapPanel'
|
||||||
|
import { AmountRow, Btn, NumInput } from '../ui'
|
||||||
|
|
||||||
|
const SLIP_BPS = 100
|
||||||
|
|
||||||
|
type SortKey = 'vol' | 'fees24' | 'tvl' | 'feeApr' | 'rewards' | null
|
||||||
|
type ProtoFilter = 'all' | 'up33' | 'univ3' | 'univ2'
|
||||||
|
|
||||||
|
export function PoolsTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const pools = usePools()
|
||||||
|
const stats = usePoolStats()
|
||||||
|
const upPrice = useUpPrice()
|
||||||
|
const { address: user } = useAccount()
|
||||||
|
const positions = usePositions(user)
|
||||||
|
const [q, setQ] = useState('') // one input: filters up33 locally + queries the indexer
|
||||||
|
const [open, setOpen] = useState<string | null>(null)
|
||||||
|
const [sort, setSort] = useState<SortKey>('tvl') // browse default: biggest pools first
|
||||||
|
const [onlyMine, setOnlyMine] = useState(false)
|
||||||
|
const [proto, setProto] = useState<ProtoFilter>('all')
|
||||||
|
const [uniQuery, setUniQuery] = useState('') // '' = whole catalog by TVL (index) / WETH pools (fallback)
|
||||||
|
const [hideDust, setHideDust] = useState(true) // 95% of the uniswap catalog is <$1k meme dust
|
||||||
|
const uni = useUniPools(uniQuery, hideDust ? 1_000 : 0, proto === 'univ2' || proto === 'univ3' ? proto : undefined)
|
||||||
|
const filterRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// typing filters the local list instantly; the catalog query follows 350ms behind
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setUniQuery(q.trim()), 350)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
// "/" focuses the filter — terminal habit
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
if (el && ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)) return
|
||||||
|
if (e.key === '/') {
|
||||||
|
e.preventDefault()
|
||||||
|
filterRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h)
|
||||||
|
return () => window.removeEventListener('keydown', h)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const mySet = useMemo(() => {
|
||||||
|
const s = new Set<string>()
|
||||||
|
positions.data?.cl.forEach((p) => s.add(p.pool.address.toLowerCase()))
|
||||||
|
positions.data?.v2.forEach((p) => s.add(p.pool.address.toLowerCase()))
|
||||||
|
return s
|
||||||
|
}, [positions.data])
|
||||||
|
|
||||||
|
if (pools.isLoading)
|
||||||
|
return (
|
||||||
|
<div className="dim">
|
||||||
|
{t('pools.loading')}
|
||||||
|
<span className="spin">▮</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (pools.isError || !pools.data)
|
||||||
|
return <div className="red">{t('pools.scanFailed', { err: String(pools.error) })}</div>
|
||||||
|
|
||||||
|
// UP33 registry pools + on-chain-verified uniswap v3 browse results, one table
|
||||||
|
const data: PoolsData = uni.data
|
||||||
|
? { ...pools.data, tokens: { ...pools.data.tokens, ...uni.data.tokens } }
|
||||||
|
: pools.data
|
||||||
|
const byPool = stats.data?.byPool
|
||||||
|
const uniStats = uni.data?.stats
|
||||||
|
const statOf = (p: Pool) => byPool?.[p.address.toLowerCase()] ?? uniStats?.[p.address.toLowerCase()]
|
||||||
|
let list = [...pools.data.pools, ...(uni.data?.pools ?? [])].filter((p) => {
|
||||||
|
if (onlyMine && !mySet.has(p.address.toLowerCase())) return false
|
||||||
|
if (proto !== 'all' && p.protocol !== proto) return false
|
||||||
|
if (!q) return true
|
||||||
|
// uniswap rows arrive already query-matched by the API (which also handles
|
||||||
|
// reversed pairs and raw addresses) — only up33 rows filter locally
|
||||||
|
if (p.protocol !== 'up33') return true
|
||||||
|
const label = `${tokenOf(data, p.token0).symbol}/${tokenOf(data, p.token1).symbol} ${poolTypeLabel(p)}`.toLowerCase()
|
||||||
|
return label.includes(q.toLowerCase())
|
||||||
|
})
|
||||||
|
if (sort) {
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
if (sort === 'vol') return (statOf(b)?.vol24hUsd ?? -1) - (statOf(a)?.vol24hUsd ?? -1)
|
||||||
|
if (sort === 'fees24') return (fees24Of(b, statOf(b)) ?? -1) - (fees24Of(a, statOf(a)) ?? -1)
|
||||||
|
if (sort === 'tvl') return (statOf(b)?.liqUsd ?? -1) - (statOf(a)?.liqUsd ?? -1)
|
||||||
|
if (sort === 'feeApr') return (feeAprOf(b, statOf(b)) ?? -1) - (feeAprOf(a, statOf(a)) ?? -1)
|
||||||
|
return (
|
||||||
|
(emitAprOf(b, statOf(b), upPrice.data) ?? -1) - (emitAprOf(a, statOf(a), upPrice.data) ?? -1)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalWeight = data.protocol.totalWeight
|
||||||
|
const th = (key: Exclude<SortKey, null>, label: string) => (
|
||||||
|
<th
|
||||||
|
className={`num sortable ${sort === key ? 'on' : ''}`}
|
||||||
|
onClick={() => setSort(sort === key ? null : key)}
|
||||||
|
title={t('pools.sortTip')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sort === key ? ' ▼' : ''}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-fill">
|
||||||
|
<div className="form-row">
|
||||||
|
<input
|
||||||
|
ref={filterRef}
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 360 }}
|
||||||
|
placeholder={t('pools.searchPlaceholder')}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['all', t('pools.protoAll'), t('pools.protoAllTip')],
|
||||||
|
['up33', 'UP33', t('pools.protoUp33Tip')],
|
||||||
|
['univ3', 'UNI V3', t('pools.protoV3Tip')],
|
||||||
|
['univ2', 'UNI V2', t('pools.protoV2Tip')],
|
||||||
|
] as const
|
||||||
|
).map(([k, label, tip]) => (
|
||||||
|
<button key={k} className={`chip ${proto === k ? 'on' : ''}`} onClick={() => setProto(k)} title={tip}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className={`chip ${hideDust ? 'on' : ''}`} onClick={() => setHideDust(!hideDust)} title={t('pools.hideDustTip')}>
|
||||||
|
{t('pools.hideDust')}
|
||||||
|
</button>
|
||||||
|
{user && mySet.size > 0 && (
|
||||||
|
<button className={`chip ${onlyMine ? 'on' : ''}`} onClick={() => setOnlyMine(!onlyMine)}>
|
||||||
|
{t('pools.mine', { n: mySet.size })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('pools.statShown', { n: list.length })} · {t('pools.statUp33', { n: pools.data.pools.length })} ·{' '}
|
||||||
|
{t('pools.statUniswap')}{' '}
|
||||||
|
{uni.isLoading ? (
|
||||||
|
<span className="spin">▮</span>
|
||||||
|
) : uni.data?.source === 'index' ? (
|
||||||
|
<>
|
||||||
|
{t('pools.statCatalog', { n: uni.data.indexed.toLocaleString('en-US') })} ·{' '}
|
||||||
|
{t('pools.statMatch', { n: uni.data.total.toLocaleString('en-US') })}
|
||||||
|
</>
|
||||||
|
) : uni.data ? (
|
||||||
|
<>{t('pools.statTop', { n: uni.data.pools.length })}</>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
{stats.isLoading && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· {t('pools.statVol')}
|
||||||
|
<span className="spin">▮</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{uni.isFetching && !uni.isLoading && <span className="spin"> ▮</span>}
|
||||||
|
{uni.isError && (
|
||||||
|
<span className="red"> · {t('pools.uniScanFailed', { err: String(uni.error).slice(0, 60) })}</span>
|
||||||
|
)}
|
||||||
|
{uni.data?.source === 'fallback' && (
|
||||||
|
<span className="amber">
|
||||||
|
{' '}
|
||||||
|
· {t('pools.fallbackNote')}
|
||||||
|
{uni.data.dropped > 0 ? <> · {t('pools.spoofDropped', { n: uni.data.dropped })}</> : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('pools.thPair')}</th>
|
||||||
|
<th>{t('pools.thPrice')}</th>
|
||||||
|
{th('tvl', t('pools.thTvl'))}
|
||||||
|
{th('vol', t('pools.thVol'))}
|
||||||
|
{th('fees24', t('pools.thFees'))}
|
||||||
|
{th('feeApr', t('pools.thFeeApr'))}
|
||||||
|
{th('rewards', t('pools.thRewards'))}
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((p) => (
|
||||||
|
<PoolRow
|
||||||
|
key={p.address}
|
||||||
|
p={p}
|
||||||
|
data={data}
|
||||||
|
stat={statOf(p)}
|
||||||
|
upUsd={upPrice.data}
|
||||||
|
wethUsd={stats.data?.wethUsd}
|
||||||
|
totalWeight={totalWeight}
|
||||||
|
mine={mySet.has(p.address.toLowerCase())}
|
||||||
|
open={open === p.address}
|
||||||
|
onToggle={() => setOpen(open === p.address ? null : p.address)}
|
||||||
|
rewardsSub={proto === 'up33'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="dim mono-sm" style={{ marginTop: 6 }} title={t('pools.footnoteTip')}>
|
||||||
|
<Trans i18nKey="pools.footnote" components={[<b className="dim" key="0" />, <b className="dim" key="1" />]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PoolRow(props: {
|
||||||
|
p: Pool
|
||||||
|
data: PoolsData
|
||||||
|
stat?: PoolStat
|
||||||
|
upUsd?: number
|
||||||
|
wethUsd?: number | null
|
||||||
|
totalWeight: bigint
|
||||||
|
mine: boolean
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
/** UP33 filter view: show the emissions detail sub-line (wide column) */
|
||||||
|
rewardsSub: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { p, data, totalWeight, stat } = props
|
||||||
|
const t0 = tokenOf(data, p.token0)
|
||||||
|
const t1 = tokenOf(data, p.token1)
|
||||||
|
const feePct = p.kind === 'v2' ? p.feeBps / 100 : p.feePpm / 10_000
|
||||||
|
const emitting = p.periodFinish > BigInt(nowSec())
|
||||||
|
const upWk = emitting ? p.rewardRate * BigInt(WEEK) : 0n
|
||||||
|
const votePct = totalWeight > 0n ? Number((p.weight * 1_000_000n) / totalWeight) / 10_000 : 0
|
||||||
|
const fees24 = fees24Of(p, stat)
|
||||||
|
const feeApr = feeAprOf(p, stat)
|
||||||
|
const emitApr = emitAprOf(p, stat, props.upUsd)
|
||||||
|
const stakedPct = stakedShareOf(p) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="rowhover">
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
{props.mine && (
|
||||||
|
<span className="mydot" title={t('pools.mineDotTip')}>
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<b>
|
||||||
|
{t0.symbol}/{t1.symbol}
|
||||||
|
</b>
|
||||||
|
{p.protocol !== 'up33' && <ProtoBadge proto={p.protocol} mini />}
|
||||||
|
</div>
|
||||||
|
<div className="pair-sub">
|
||||||
|
<span className="cyan">
|
||||||
|
{p.kind === 'v2'
|
||||||
|
? p.protocol === 'univ2'
|
||||||
|
? 'v2'
|
||||||
|
: p.stable
|
||||||
|
? 'v2 stable'
|
||||||
|
: 'v2 volatile'
|
||||||
|
: p.protocol === 'univ3'
|
||||||
|
? `v3 ts${p.tickSpacing}`
|
||||||
|
: `CL ts${p.tickSpacing}`}
|
||||||
|
</span>
|
||||||
|
{' · '}
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
p.protocol === 'up33' ? undefined : p.kind === 'v2' ? t('pools.feeTipUniV2') : t('pools.feeTipUniV3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{feePct.toFixed(feePct < 0.1 ? 3 : 2)}%
|
||||||
|
</span>
|
||||||
|
{p.protocol === 'up33' && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
{p.gauge ? (
|
||||||
|
p.gaugeAlive ? (
|
||||||
|
<span className="green">{t('pools.gauge')}</span>
|
||||||
|
) : (
|
||||||
|
<span className="red">{t('pools.killed')}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>{t('pools.noGauge')}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="mono-sm">
|
||||||
|
{p.kind === 'v2' ? (
|
||||||
|
<>
|
||||||
|
{fmtCompactAmount(p.reserve0, t0.decimals)} {t0.symbol} + {fmtCompactAmount(p.reserve1, t1.decimals)} {t1.symbol}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<PxCell sqrtPriceX96={p.sqrtPriceX96} d0={t0.decimals} d1={t1.decimals} s0={t0.symbol} s1={t1.symbol} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{stat?.liqUsd != null && stat.liqUsd > 0 ? fmtUsd(stat.liqUsd) : <span className="dim">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{stat?.vol24hUsd != null ? fmtUsd(stat.vol24hUsd) : <span className="dim">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{fees24 != null ? <span className="amber">{fmtUsd(fees24)}</span> : <span className="dim">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="num" title="unstaked LP net fee yield (staked LPs earn 0 fees)">
|
||||||
|
{feeApr != null ? fmtApr(feeApr) : <span className="dim">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="num" title={t('pools.rewardsTip')}>
|
||||||
|
{p.protocol === 'up33' ? (
|
||||||
|
<>
|
||||||
|
{emitApr != null ? <span className="green">{fmtApr(emitApr)}</span> : <span className="dim">—</span>}
|
||||||
|
{props.rewardsSub && (
|
||||||
|
<span className="cell-sub">
|
||||||
|
{upWk > 0n ? t('pools.upWk', { n: fmtCompactAmount(upWk, 18) }) : t('pools.noEmissions')}
|
||||||
|
{votePct > 0 ? ` · ${t('pools.vote', { n: votePct.toFixed(2) })}` : ''}
|
||||||
|
{stakedPct > 0 ? ` · ${t('pools.stakedPct', { n: stakedPct.toFixed(0) })}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="dim">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<Btn tone="ghost" onClick={props.onToggle}>
|
||||||
|
{props.open ? t('common.close') : t('pools.addLp')}
|
||||||
|
</Btn>{' '}
|
||||||
|
<a href={`${EXPLORER}/address/${p.address}`} target="_blank" rel="noreferrer" className="dim">
|
||||||
|
↗
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{props.open && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8}>
|
||||||
|
{p.kind === 'v2' ? (
|
||||||
|
<AddV2 pool={p} data={data} stat={stat} upUsd={props.upUsd} />
|
||||||
|
) : (
|
||||||
|
<AddCl pool={p} data={data} stat={stat} upUsd={props.upUsd} wethUsd={props.wethUsd} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pool price, auto-oriented so the number is >= 1 (small prices flip quote/base) */
|
||||||
|
function PxCell(props: { sqrtPriceX96: bigint; d0: number; d1: number; s0: string; s1: string }) {
|
||||||
|
const px = sqrtPriceToPrice(props.sqrtPriceX96, props.d0, props.d1)
|
||||||
|
// absurd magnitudes = pool initialized at a nonsense price (usually zero liquidity)
|
||||||
|
if (!Number.isFinite(px) || px <= 1e-15 || px >= 1e15) return <span className="dim">—</span>
|
||||||
|
const flip = px < 1
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flash v={flip ? 1 / px : px}>
|
||||||
|
<span>{fmtNum(flip ? 1 / px : px)}</span>
|
||||||
|
</Flash>{' '}
|
||||||
|
<span className="dim">{flip ? `${props.s0}/${props.s1}` : `${props.s1}/${props.s0}`}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** projected APRs line shown in the add-LP panels; emitless = no gauge system (univ3) */
|
||||||
|
function SimLine({ sim, emitless }: { sim: AddSim | null; emitless?: boolean }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!sim) return null
|
||||||
|
if (!sim.inRange)
|
||||||
|
return (
|
||||||
|
<div className="form-row sim-line mono-sm">
|
||||||
|
<span className="lbl">{t('add.projected')}</span>
|
||||||
|
<span className="red">{t('add.projOut')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="form-row sim-line mono-sm">
|
||||||
|
<span className="lbl">{t('add.projected')}</span>
|
||||||
|
<span className="dim">{t('add.projDep', { usd: fmtUsd(sim.depositUsd) })}</span>
|
||||||
|
<span>
|
||||||
|
{t('add.projFeeApr')} {!emitless && <span className="dim">{t('add.projIfUnstaked')}</span>} ≈{' '}
|
||||||
|
<b>{fmtApr(sim.feeApr)}</b>
|
||||||
|
</span>
|
||||||
|
{!emitless && (
|
||||||
|
<span>
|
||||||
|
{t('add.projEmitApr')} <span className="dim">{t('add.projIfStaked')}</span> ≈{' '}
|
||||||
|
<b className="green">{fmtApr(sim.emitApr)}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="dim">{t('add.projShare', { pct: sim.sharePct < 0.01 ? '<0.01' : sim.sharePct.toFixed(2) })}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- add liquidity: v2 ----------------
|
||||||
|
|
||||||
|
export function AddV2({ pool, data, stat, upUsd }: { pool: V2Pool; data: PoolsData; stat?: PoolStat; upUsd?: number }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { address: user } = useAccount()
|
||||||
|
const t0 = tokenOf(data, pool.token0)
|
||||||
|
const t1 = tokenOf(data, pool.token1)
|
||||||
|
const [fund, setFund] = useState<'pair' | 'zap'>('pair')
|
||||||
|
const [a0, setA0] = useState('')
|
||||||
|
const [a1, setA1] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const bal = useBalances(user, [pool.token0, pool.token1])
|
||||||
|
|
||||||
|
const link = (v: string, is0: boolean) => {
|
||||||
|
if (is0) setA0(v)
|
||||||
|
else setA1(v)
|
||||||
|
if (pool.reserve0 === 0n || pool.reserve1 === 0n) return
|
||||||
|
try {
|
||||||
|
const amt = parseUnits(v === '' ? '0' : v, is0 ? t0.decimals : t1.decimals)
|
||||||
|
if (is0) {
|
||||||
|
const other = (amt * pool.reserve1) / pool.reserve0
|
||||||
|
setA1(trim(formatUnits(other, t1.decimals)))
|
||||||
|
} else {
|
||||||
|
const other = (amt * pool.reserve0) / pool.reserve1
|
||||||
|
setA0(trim(formatUnits(other, t0.decimals)))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* partial input */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sim = useMemo(
|
||||||
|
() =>
|
||||||
|
simulateV2Add({
|
||||||
|
pool,
|
||||||
|
amount0h: Number(a0) || 0,
|
||||||
|
amount1h: Number(a1) || 0,
|
||||||
|
dec0: t0.decimals,
|
||||||
|
dec1: t1.decimals,
|
||||||
|
stat,
|
||||||
|
upUsd,
|
||||||
|
}),
|
||||||
|
[pool, a0, a1, t0.decimals, t1.decimals, stat, upUsd],
|
||||||
|
)
|
||||||
|
|
||||||
|
const uni2 = pool.protocol === 'univ2'
|
||||||
|
const router = uni2 ? UNI.V2_ROUTER : ADDR.V2_ROUTER
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!user) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const amt0 = safeParse(a0, t0.decimals)
|
||||||
|
const amt1 = safeParse(a1, t1.decimals)
|
||||||
|
if (amt0 === 0n || amt1 === 0n) return
|
||||||
|
if (!(await ensureAllowance(pool.token0, user, router, amt0, t0.symbol))) return
|
||||||
|
if (!(await ensureAllowance(pool.token1, user, router, amt1, t1.symbol))) return
|
||||||
|
if (uni2) {
|
||||||
|
// vanilla Router02: no on-chain quote helper — amounts are already
|
||||||
|
// reserve-ratio-linked by the UI, the router pins the optimal ratio
|
||||||
|
// and the mins bound the drift since linking
|
||||||
|
await step(t('add.stepAddV2', { pair: `${t0.symbol}/${t1.symbol}` }), () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: uniV2RouterAbi,
|
||||||
|
address: UNI.V2_ROUTER,
|
||||||
|
functionName: 'addLiquidity',
|
||||||
|
args: [
|
||||||
|
pool.token0,
|
||||||
|
pool.token1,
|
||||||
|
amt0,
|
||||||
|
amt1,
|
||||||
|
applySlippage(amt0, SLIP_BPS),
|
||||||
|
applySlippage(amt1, SLIP_BPS),
|
||||||
|
user,
|
||||||
|
deadline(),
|
||||||
|
],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const quote = await readContract(wagmiConfig, {
|
||||||
|
abi: v2RouterAbi,
|
||||||
|
address: ADDR.V2_ROUTER,
|
||||||
|
functionName: 'quoteAddLiquidity',
|
||||||
|
args: [pool.token0, pool.token1, pool.stable, ADDR.V2_FACTORY, amt0, amt1],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
})
|
||||||
|
await step(t('add.stepAdd', { pair: `${t0.symbol}/${t1.symbol}` }), () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: v2RouterAbi,
|
||||||
|
address: ADDR.V2_ROUTER,
|
||||||
|
functionName: 'addLiquidity',
|
||||||
|
args: [
|
||||||
|
pool.token0,
|
||||||
|
pool.token1,
|
||||||
|
pool.stable,
|
||||||
|
amt0,
|
||||||
|
amt1,
|
||||||
|
applySlippage(quote[0], SLIP_BPS),
|
||||||
|
applySlippage(quote[1], SLIP_BPS),
|
||||||
|
user,
|
||||||
|
deadline(),
|
||||||
|
],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="expander">
|
||||||
|
<FundSwitch fund={fund} onFund={setFund} />
|
||||||
|
{fund === 'zap' ? (
|
||||||
|
<ZapPanel target={{ kind: 'v2', pool }} t0={t0} t1={t1} stat={stat} upUsd={upUsd} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AmountRow
|
||||||
|
sym={t0.symbol}
|
||||||
|
value={a0}
|
||||||
|
onChange={(v) => link(v, true)}
|
||||||
|
bal={bal.data?.[pool.token0.toLowerCase()]}
|
||||||
|
dec={t0.decimals}
|
||||||
|
onMax={(v) => link(v, true)}
|
||||||
|
/>
|
||||||
|
<AmountRow
|
||||||
|
sym={t1.symbol}
|
||||||
|
value={a1}
|
||||||
|
onChange={(v) => link(v, false)}
|
||||||
|
bal={bal.data?.[pool.token1.toLowerCase()]}
|
||||||
|
dec={t1.decimals}
|
||||||
|
onMax={(v) => link(v, false)}
|
||||||
|
/>
|
||||||
|
<SimLine sim={sim} emitless={uni2} />
|
||||||
|
<div className="form-row">
|
||||||
|
<Btn busy={busy} onClick={add} disabled={!user}>
|
||||||
|
{t('add.addLiquidity')}
|
||||||
|
</Btn>
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('add.v2Hint', { slip: SLIP_BPS / 100 })} · {uni2 ? t('add.v2HintUni') : t('add.v2HintUp33')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PAIR = supply both tokens yourself · ZAP = fund with one token, the
|
||||||
|
* terminal swaps the right slice into the counter-token first */
|
||||||
|
export function FundSwitch(props: { fund: 'pair' | 'zap'; onFund: (f: 'pair' | 'zap') => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('add.fund')}</span>
|
||||||
|
<button
|
||||||
|
className={`chip ${props.fund === 'pair' ? 'on' : ''}`}
|
||||||
|
onClick={() => props.onFund('pair')}
|
||||||
|
title={t('add.fundPairTip')}
|
||||||
|
>
|
||||||
|
{t('add.fundPair')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`chip ${props.fund === 'zap' ? 'on' : ''}`}
|
||||||
|
onClick={() => props.onFund('zap')}
|
||||||
|
title={t('add.fundZapTip')}
|
||||||
|
>
|
||||||
|
{t('add.fundZap')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- add liquidity: CL ----------------
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ id: 'p05', label: '±0.5%', pct: 0.005 },
|
||||||
|
{ id: 'p1', label: '±1%', pct: 0.01 },
|
||||||
|
{ id: 'p2', label: '±2%', pct: 0.02 },
|
||||||
|
{ id: 'p5', label: '±5%', pct: 0.05 },
|
||||||
|
{ id: 'p10', label: '±10%', pct: 0.1 },
|
||||||
|
{ id: 'p20', label: '±20%', pct: 0.2 },
|
||||||
|
{ id: 'p30', label: '±30%', pct: 0.3 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type RangeMode = (typeof PRESETS)[number]['id'] | 'full' | 'pct' | 'above' | 'below' | 'price' | 'ticks'
|
||||||
|
|
||||||
|
function symRange(tick: number, pct: number, spacing: number) {
|
||||||
|
const d = tickDeltaForPct(pct)
|
||||||
|
const lower = alignTick(tick - d, spacing, 'floor')
|
||||||
|
let upper = alignTick(tick + d, spacing, 'ceil')
|
||||||
|
if (upper <= lower) upper = lower + spacing
|
||||||
|
return { lower, upper }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** plain decimal string (no grouping) for editable price inputs */
|
||||||
|
function plainNum(x: number): string {
|
||||||
|
if (!Number.isFinite(x)) return ''
|
||||||
|
const s = x >= 1 ? x.toPrecision(7) : x.toPrecision(5)
|
||||||
|
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCl({
|
||||||
|
pool,
|
||||||
|
data,
|
||||||
|
stat,
|
||||||
|
upUsd,
|
||||||
|
wethUsd,
|
||||||
|
}: {
|
||||||
|
pool: ClPool
|
||||||
|
data: PoolsData
|
||||||
|
stat?: PoolStat
|
||||||
|
upUsd?: number
|
||||||
|
wethUsd?: number | null
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { address: user } = useAccount()
|
||||||
|
const t0 = tokenOf(data, pool.token0)
|
||||||
|
const t1 = tokenOf(data, pool.token1)
|
||||||
|
const [fund, setFund] = useState<'pair' | 'zap'>('pair')
|
||||||
|
const [mode, setMode] = useState<RangeMode>('p10')
|
||||||
|
const [pctStr, setPctStr] = useState('10')
|
||||||
|
const [priceLo, setPriceLo] = useState('')
|
||||||
|
const [priceHi, setPriceHi] = useState('')
|
||||||
|
const [custom, setCustom] = useState<{ lower: string; upper: string }>({ lower: '', upper: '' })
|
||||||
|
const [a0, setA0] = useState('')
|
||||||
|
const [a1, setA1] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const bal = useBalances(user, [pool.token0, pool.token1])
|
||||||
|
|
||||||
|
const ticks = useMemo(() => {
|
||||||
|
const s = pool.tickSpacing
|
||||||
|
const preset = PRESETS.find((x) => x.id === mode)
|
||||||
|
if (preset) return symRange(pool.tick, preset.pct, s)
|
||||||
|
if (mode === 'full') return fullRangeTicks(s)
|
||||||
|
if (mode === 'pct') {
|
||||||
|
const pct = Number(pctStr) / 100
|
||||||
|
return pct > 0 ? symRange(pool.tick, pct, s) : null
|
||||||
|
}
|
||||||
|
if (mode === 'above' || mode === 'below') {
|
||||||
|
// one-sided: range starts at the current price and extends one way.
|
||||||
|
// ABOVE deposits token0 only (sells into rises); BELOW token1 only.
|
||||||
|
const pct = Number(pctStr) / 100
|
||||||
|
if (!(pct > 0)) return null
|
||||||
|
const d = tickDeltaForPct(pct)
|
||||||
|
if (mode === 'above') {
|
||||||
|
const lower = alignTick(pool.tick, s, 'ceil')
|
||||||
|
let upper = alignTick(pool.tick + d, s, 'ceil')
|
||||||
|
if (upper <= lower) upper = lower + s
|
||||||
|
return { lower, upper }
|
||||||
|
}
|
||||||
|
const upper = alignTick(pool.tick, s, 'floor')
|
||||||
|
let lower = alignTick(pool.tick - d, s, 'floor')
|
||||||
|
if (lower >= upper) lower = upper - s
|
||||||
|
return { lower, upper }
|
||||||
|
}
|
||||||
|
if (mode === 'price') {
|
||||||
|
const lo = Number(priceLo)
|
||||||
|
const hi = Number(priceHi)
|
||||||
|
if (!(lo > 0) || !(hi > 0) || lo >= hi) return null
|
||||||
|
const tA = priceToTick(lo, t0.decimals, t1.decimals)
|
||||||
|
const tB = priceToTick(hi, t0.decimals, t1.decimals)
|
||||||
|
const lower = alignTick(Math.min(tA, tB), s, 'floor')
|
||||||
|
let upper = alignTick(Math.max(tA, tB), s, 'ceil')
|
||||||
|
if (upper <= lower) upper = lower + s
|
||||||
|
return { lower, upper }
|
||||||
|
}
|
||||||
|
const lo = parseInt(custom.lower, 10)
|
||||||
|
const hi = parseInt(custom.upper, 10)
|
||||||
|
if (Number.isFinite(lo) && Number.isFinite(hi) && lo < hi)
|
||||||
|
return { lower: alignTick(lo, s, 'floor'), upper: alignTick(hi, s, 'ceil') }
|
||||||
|
return null
|
||||||
|
}, [mode, pctStr, priceLo, priceHi, custom, pool.tick, pool.tickSpacing, t0.decimals, t1.decimals])
|
||||||
|
|
||||||
|
const enterPriceMode = () => {
|
||||||
|
const base = ticks ?? symRange(pool.tick, 0.1, pool.tickSpacing)
|
||||||
|
setPriceLo(plainNum(tickToPrice(base.lower, t0.decimals, t1.decimals)))
|
||||||
|
setPriceHi(plainNum(tickToPrice(base.upper, t0.decimals, t1.decimals)))
|
||||||
|
setMode('price')
|
||||||
|
}
|
||||||
|
|
||||||
|
const below = ticks ? pool.tick < ticks.lower : false
|
||||||
|
const above = ticks ? pool.tick >= ticks.upper : false
|
||||||
|
|
||||||
|
const link = (v: string, is0: boolean) => {
|
||||||
|
if (is0) setA0(v)
|
||||||
|
else setA1(v)
|
||||||
|
if (!ticks) return
|
||||||
|
try {
|
||||||
|
const amt = parseUnits(v === '' ? '0' : v, is0 ? t0.decimals : t1.decimals)
|
||||||
|
const prev = previewDeposit(pool.sqrtPriceX96, ticks.lower, ticks.upper, amt, is0)
|
||||||
|
if (!prev) return
|
||||||
|
if (is0) setA1(prev.amount1 === 0n ? '0' : trim(formatUnits(prev.amount1, t1.decimals)))
|
||||||
|
else setA0(prev.amount0 === 0n ? '0' : trim(formatUnits(prev.amount0, t0.decimals)))
|
||||||
|
} catch {
|
||||||
|
/* partial input */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// range changed -> rebalance the linked amounts to the new band
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ticks) return
|
||||||
|
if (a0 && a0 !== '0' && !above) link(a0, true)
|
||||||
|
else if (a1 && a1 !== '0' && !below) link(a1, false)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ticks?.lower, ticks?.upper])
|
||||||
|
|
||||||
|
const sim = useMemo(() => {
|
||||||
|
if (!ticks) return null
|
||||||
|
const amt0 = safeParse(a0, t0.decimals)
|
||||||
|
const amt1 = safeParse(a1, t1.decimals)
|
||||||
|
if (amt0 === 0n && amt1 === 0n) return null
|
||||||
|
const liq = getLiquidityForAmounts(
|
||||||
|
pool.sqrtPriceX96,
|
||||||
|
getSqrtRatioAtTick(ticks.lower),
|
||||||
|
getSqrtRatioAtTick(ticks.upper),
|
||||||
|
amt0,
|
||||||
|
amt1,
|
||||||
|
)
|
||||||
|
return simulateClAdd({
|
||||||
|
pool,
|
||||||
|
tickLower: ticks.lower,
|
||||||
|
tickUpper: ticks.upper,
|
||||||
|
liquidity: liq,
|
||||||
|
amount0h: Number(a0) || 0,
|
||||||
|
amount1h: Number(a1) || 0,
|
||||||
|
dec0: t0.decimals,
|
||||||
|
dec1: t1.decimals,
|
||||||
|
stat,
|
||||||
|
upUsd,
|
||||||
|
wethUsd,
|
||||||
|
})
|
||||||
|
}, [ticks, a0, a1, pool, t0.decimals, t1.decimals, stat, upUsd, wethUsd])
|
||||||
|
|
||||||
|
const mint = async () => {
|
||||||
|
if (!user || !ticks) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const amt0 = safeParse(a0, t0.decimals)
|
||||||
|
const amt1 = safeParse(a1, t1.decimals)
|
||||||
|
if (amt0 === 0n && amt1 === 0n) return
|
||||||
|
// univ3 and Slipstream NPMs share everything except mint's struct shape
|
||||||
|
const npm = pool.protocol === 'univ3' ? UNI.V3_NPM : ADDR.CL_PM
|
||||||
|
if (amt0 > 0n && !(await ensureAllowance(pool.token0, user, npm, amt0, t0.symbol))) return
|
||||||
|
if (amt1 > 0n && !(await ensureAllowance(pool.token1, user, npm, amt1, t1.symbol))) return
|
||||||
|
// fresh price + band-edge mins (see minAmountsForLiquidity) — avoids 'PS' reverts
|
||||||
|
const sqrtP = await fetchSqrtPriceX96(pool.address)
|
||||||
|
const sqrtA = getSqrtRatioAtTick(ticks.lower)
|
||||||
|
const sqrtB = getSqrtRatioAtTick(ticks.upper)
|
||||||
|
const liq = getLiquidityForAmounts(sqrtP, sqrtA, sqrtB, amt0, amt1)
|
||||||
|
const mins = minAmountsForLiquidity(sqrtP, sqrtA, sqrtB, liq, SLIP_BPS)
|
||||||
|
const common = {
|
||||||
|
token0: pool.token0,
|
||||||
|
token1: pool.token1,
|
||||||
|
tickLower: ticks.lower,
|
||||||
|
tickUpper: ticks.upper,
|
||||||
|
amount0Desired: amt0,
|
||||||
|
amount1Desired: amt1,
|
||||||
|
amount0Min: mins.amount0Min,
|
||||||
|
amount1Min: mins.amount1Min,
|
||||||
|
recipient: user,
|
||||||
|
deadline: deadline(),
|
||||||
|
}
|
||||||
|
await step(
|
||||||
|
t('add.stepMint', {
|
||||||
|
kind: pool.protocol === 'univ3' ? 'v3' : 'CL',
|
||||||
|
pair: `${t0.symbol}/${t1.symbol}`,
|
||||||
|
lo: ticks.lower,
|
||||||
|
hi: ticks.upper,
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
pool.protocol === 'univ3'
|
||||||
|
? writeContract(wagmiConfig, {
|
||||||
|
abi: uniV3PmAbi,
|
||||||
|
address: UNI.V3_NPM,
|
||||||
|
functionName: 'mint',
|
||||||
|
args: [{ ...common, fee: pool.feePpm }],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
})
|
||||||
|
: writeContract(wagmiConfig, {
|
||||||
|
abi: clPmAbi,
|
||||||
|
address: ADDR.CL_PM,
|
||||||
|
functionName: 'mint',
|
||||||
|
args: [{ ...common, tickSpacing: pool.tickSpacing, sqrtPriceX96: 0n }],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="expander">
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{t('add.range')}</span>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button key={p.id} className={`chip ${mode === p.id ? 'on' : ''}`} onClick={() => setMode(p.id)}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className={`chip ${mode === 'full' ? 'on' : ''}`} onClick={() => setMode('full')}>
|
||||||
|
{t('add.full')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl"></span>
|
||||||
|
<button className={`chip ${mode === 'pct' ? 'on' : ''}`} onClick={() => setMode('pct')} title={t('add.pctCustomTip')}>
|
||||||
|
{t('add.pctCustom')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`chip ${mode === 'above' ? 'on' : ''}`}
|
||||||
|
onClick={() => setMode('above')}
|
||||||
|
title={t('add.aboveTip', { sym: t0.symbol })}
|
||||||
|
>
|
||||||
|
{t('add.above')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`chip ${mode === 'below' ? 'on' : ''}`}
|
||||||
|
onClick={() => setMode('below')}
|
||||||
|
title={t('add.belowTip', { sym: t1.symbol })}
|
||||||
|
>
|
||||||
|
{t('add.below')}
|
||||||
|
</button>
|
||||||
|
<button className={`chip ${mode === 'price' ? 'on' : ''}`} onClick={enterPriceMode} title={t('add.priceTip')}>
|
||||||
|
{t('add.price')}
|
||||||
|
</button>
|
||||||
|
<button className={`chip ${mode === 'ticks' ? 'on' : ''}`} onClick={() => setMode('ticks')} title={t('add.ticksTip')}>
|
||||||
|
{t('add.ticks')}
|
||||||
|
</button>
|
||||||
|
{(mode === 'pct' || mode === 'above' || mode === 'below') && (
|
||||||
|
<>
|
||||||
|
<span className="dim">{mode === 'above' ? '+' : mode === 'below' ? '−' : '±'}</span>
|
||||||
|
<NumInput value={pctStr} onChange={setPctStr} width={70} placeholder="10" />
|
||||||
|
<span className="dim">%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mode === 'price' && (
|
||||||
|
<>
|
||||||
|
<NumInput value={priceLo} onChange={setPriceLo} width={130} placeholder={t('add.priceLo')} />
|
||||||
|
<span className="dim">→</span>
|
||||||
|
<NumInput value={priceHi} onChange={setPriceHi} width={130} placeholder={t('add.priceHi')} />
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('add.priceUnits', { quote: t1.symbol, base: t0.symbol, ts: pool.tickSpacing })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mode === 'ticks' && (
|
||||||
|
<>
|
||||||
|
<NumInputSigned value={custom.lower} onChange={(v) => setCustom({ ...custom, lower: v })} placeholder={t('add.tickLower')} />
|
||||||
|
<NumInputSigned value={custom.upper} onChange={(v) => setCustom({ ...custom, upper: v })} placeholder={t('add.tickUpper')} />
|
||||||
|
<span className="dim mono-sm">{t('add.spacing', { ts: pool.tickSpacing })}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(mode === 'above' || mode === 'below') && (
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('add.limitHint')} <a href="#limit">{t('add.limitHintLink')}</a> {t('add.limitHintRest')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ticks && (
|
||||||
|
<RangeBar
|
||||||
|
tickLower={ticks.lower}
|
||||||
|
tickUpper={ticks.upper}
|
||||||
|
tick={pool.tick}
|
||||||
|
sqrtPriceX96={pool.sqrtPriceX96}
|
||||||
|
dec0={t0.decimals}
|
||||||
|
dec1={t1.decimals}
|
||||||
|
sym0={t0.symbol}
|
||||||
|
sym1={t1.symbol}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FundSwitch fund={fund} onFund={setFund} />
|
||||||
|
{fund === 'zap' ? (
|
||||||
|
ticks ? (
|
||||||
|
<ZapPanel
|
||||||
|
target={{ kind: 'cl-mint', pool, tickLower: ticks.lower, tickUpper: ticks.upper }}
|
||||||
|
t0={t0}
|
||||||
|
t1={t1}
|
||||||
|
stat={stat}
|
||||||
|
upUsd={upUsd}
|
||||||
|
wethUsd={wethUsd}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="dim mono-sm">{t('add.setRangeFirst')}</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AmountRow
|
||||||
|
sym={t0.symbol}
|
||||||
|
value={a0}
|
||||||
|
onChange={(v) => link(v, true)}
|
||||||
|
bal={bal.data?.[pool.token0.toLowerCase()]}
|
||||||
|
dec={t0.decimals}
|
||||||
|
onMax={(v) => link(v, true)}
|
||||||
|
disabled={above}
|
||||||
|
note={above ? t('add.aboveNote') : undefined}
|
||||||
|
/>
|
||||||
|
<AmountRow
|
||||||
|
sym={t1.symbol}
|
||||||
|
value={a1}
|
||||||
|
onChange={(v) => link(v, false)}
|
||||||
|
bal={bal.data?.[pool.token1.toLowerCase()]}
|
||||||
|
dec={t1.decimals}
|
||||||
|
onMax={(v) => link(v, false)}
|
||||||
|
disabled={below}
|
||||||
|
note={below ? t('add.belowNote') : undefined}
|
||||||
|
/>
|
||||||
|
<SimLine sim={sim} emitless={pool.protocol === 'univ3'} />
|
||||||
|
<div className="form-row">
|
||||||
|
<Btn busy={busy} onClick={mint} disabled={!user || !ticks}>
|
||||||
|
{t('add.mint')}
|
||||||
|
</Btn>
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{pool.protocol === 'univ3' ? t('add.mintHintUni') : t('add.mintHintUp33')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- shared bits ----------------
|
||||||
|
|
||||||
|
function NumInputSigned(props: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ width: 110 }}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
value={props.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (v === '' || /^-?\d*$/.test(v)) props.onChange(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(s: string): string {
|
||||||
|
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s
|
||||||
|
}
|
||||||
|
function safeParse(s: string, dec: number): bigint {
|
||||||
|
try {
|
||||||
|
return parseUnits(s === '' ? '0' : s, dec)
|
||||||
|
} catch {
|
||||||
|
return 0n
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,509 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAccount } from 'wagmi'
|
||||||
|
import { sendTransaction, writeContract } from 'wagmi/actions'
|
||||||
|
import { formatUnits, parseUnits, type Address } from 'viem'
|
||||||
|
import { clSwapRouterAbi, v2RouterAbi, wethAbi } from '../../abi'
|
||||||
|
import { ADDR, CHAIN_ID } from '../../config/addresses'
|
||||||
|
import { ENV } from '../../config/env'
|
||||||
|
import { wagmiConfig } from '../../config/wagmi'
|
||||||
|
import { applySlippage } from '../../lib/clmath'
|
||||||
|
import { bpsDiff, fmtAmount, fmtNum, fmtUsd, shortAddr } from '../../lib/format'
|
||||||
|
import { routeBreakdown } from '../../lib/kyber'
|
||||||
|
import { buildGatedKyberTx } from '../../lib/kyberExec'
|
||||||
|
import { peekSwapIntent, takeSwapIntent } from '../../lib/swapIntent'
|
||||||
|
import { deadline, ensureAllowance, step } from '../../lib/tx'
|
||||||
|
import { txlog } from '../../lib/txlog'
|
||||||
|
import { useBalances } from '../../hooks/useBalances'
|
||||||
|
import { erc20Of, isNative, useKyberQuote, useNativeQuote } from '../../hooks/useQuotes'
|
||||||
|
import { useTokenList } from '../../hooks/useTokenList'
|
||||||
|
import type { TokenInfo } from '../../types'
|
||||||
|
import { TokenSelect } from '../TokenSelect'
|
||||||
|
import { Badge, Btn } from '../ui'
|
||||||
|
import { LimitPanel } from './LimitPanel'
|
||||||
|
|
||||||
|
const ETH_GAS_BUFFER = parseUnits('0.001', 18)
|
||||||
|
|
||||||
|
type SwapMode = 'market' | 'limit'
|
||||||
|
|
||||||
|
export function SwapTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { address: user } = useAccount()
|
||||||
|
const list = useTokenList()
|
||||||
|
|
||||||
|
const [mode, setModeState] = useState<SwapMode>(() => (location.hash === '#limit' ? 'limit' : 'market'))
|
||||||
|
const [tIn, setTIn] = useState<TokenInfo | null>(null)
|
||||||
|
const [tOut, setTOut] = useState<TokenInfo | null>(null)
|
||||||
|
const [amtStr, setAmtStr] = useState('')
|
||||||
|
const [amount, setAmount] = useState<bigint>(0n)
|
||||||
|
const [slip, setSlip] = useState(50)
|
||||||
|
const [override, setOverride] = useState<'kyber' | 'native' | null>(null)
|
||||||
|
const [invRate, setInvRate] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const setMode = (m: SwapMode) => {
|
||||||
|
setModeState(m)
|
||||||
|
history.replaceState(null, '', m === 'limit' ? '#limit' : '#swap')
|
||||||
|
}
|
||||||
|
|
||||||
|
// deep links: #limit / #swap navigation while this tab is already mounted
|
||||||
|
useEffect(() => {
|
||||||
|
const onHash = () => {
|
||||||
|
if (location.hash === '#limit') setModeState('limit')
|
||||||
|
else if (location.hash === '#swap') setModeState('market')
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', onHash)
|
||||||
|
return () => window.removeEventListener('hashchange', onHash)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// consume a pending "swap claimed UP" handoff once both tokens are resolvable
|
||||||
|
useEffect(() => {
|
||||||
|
const i = peekSwapIntent()
|
||||||
|
if (!i || !list.length) return
|
||||||
|
const tin = list.find((t) => t.address.toLowerCase() === i.tokenIn.toLowerCase())
|
||||||
|
const tout = list.find((t) => t.address.toLowerCase() === i.tokenOut.toLowerCase())
|
||||||
|
if (!tin || !tout) return // token list still loading — try again next render
|
||||||
|
takeSwapIntent()
|
||||||
|
setModeState('market')
|
||||||
|
setTIn(tin)
|
||||||
|
setTOut(tout)
|
||||||
|
setAmtStr(formatUnits(i.amount, tin.decimals))
|
||||||
|
}, [list])
|
||||||
|
|
||||||
|
// defaults: ETH -> UP. Wait until UP is actually present (pool tokens load async)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tIn && list.length) setTIn(list.find((t) => t.native) ?? list[0])
|
||||||
|
if (!tOut) {
|
||||||
|
const up = list.find((t) => t.address.toLowerCase() === ADDR.UP.toLowerCase())
|
||||||
|
if (up) setTOut(up)
|
||||||
|
}
|
||||||
|
}, [list, tIn, tOut])
|
||||||
|
|
||||||
|
// debounce amount parsing
|
||||||
|
useEffect(() => {
|
||||||
|
const h = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
setAmount(tIn ? parseUnits(amtStr === '' ? '0' : amtStr, tIn.decimals) : 0n)
|
||||||
|
} catch {
|
||||||
|
setAmount(0n)
|
||||||
|
}
|
||||||
|
}, 350)
|
||||||
|
return () => clearTimeout(h)
|
||||||
|
}, [amtStr, tIn])
|
||||||
|
|
||||||
|
useEffect(() => setOverride(null), [tIn?.address, tOut?.address, amount])
|
||||||
|
|
||||||
|
const bal = useBalances(user, [tIn?.address, tOut?.address].filter(Boolean) as Address[])
|
||||||
|
|
||||||
|
const isWrap = !!tIn?.native && tOut?.address.toLowerCase() === ADDR.WETH.toLowerCase()
|
||||||
|
const isUnwrap = !!tOut?.native && tIn?.address.toLowerCase() === ADDR.WETH.toLowerCase()
|
||||||
|
|
||||||
|
const kyber = useKyberQuote(tIn?.address, tOut?.address, amount)
|
||||||
|
const native = useNativeQuote(tIn?.address, tOut?.address, amount)
|
||||||
|
|
||||||
|
const kyberOut = kyber.data ? BigInt(kyber.data.routeSummary.amountOut) : undefined
|
||||||
|
const nativeBest = native.data?.best ?? null
|
||||||
|
|
||||||
|
const auto: 'kyber' | 'native' | null =
|
||||||
|
kyberOut !== undefined && nativeBest
|
||||||
|
? kyberOut >= nativeBest.amountOut
|
||||||
|
? 'kyber'
|
||||||
|
: 'native'
|
||||||
|
: kyberOut !== undefined
|
||||||
|
? 'kyber'
|
||||||
|
: nativeBest
|
||||||
|
? 'native'
|
||||||
|
: null
|
||||||
|
const sel = override ?? auto
|
||||||
|
const selOut = sel === 'kyber' ? kyberOut : sel === 'native' ? nativeBest?.amountOut : undefined
|
||||||
|
|
||||||
|
const flip = () => {
|
||||||
|
const a = tIn
|
||||||
|
setTIn(tOut)
|
||||||
|
setTOut(a)
|
||||||
|
setAmtStr('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doWrap = () =>
|
||||||
|
run(() =>
|
||||||
|
step(t('swap.stWrap', { amt: amtStr }), () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: wethAbi,
|
||||||
|
address: ADDR.WETH,
|
||||||
|
functionName: 'deposit',
|
||||||
|
value: amount,
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const doUnwrap = () =>
|
||||||
|
run(() =>
|
||||||
|
step(t('swap.stUnwrap', { amt: amtStr }), () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: wethAbi,
|
||||||
|
address: ADDR.WETH,
|
||||||
|
functionName: 'withdraw',
|
||||||
|
args: [amount],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const doSwap = () =>
|
||||||
|
run(async () => {
|
||||||
|
if (!user || !tIn || !tOut || amount === 0n || !sel) return
|
||||||
|
if (sel === 'kyber') {
|
||||||
|
const fresh = await kyber.refetch()
|
||||||
|
const data = fresh.data
|
||||||
|
if (!data) {
|
||||||
|
txlog.push('err', t('swap.errNoQuote'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isNative(tIn.address)) {
|
||||||
|
if (!(await ensureAllowance(tIn.address, user, ENV.kyberRouter, amount, tIn.symbol))) return
|
||||||
|
}
|
||||||
|
// build + safety gates (router whitelist, tx value, build-vs-quote drift)
|
||||||
|
// live in lib/kyberExec — shared with ZAP so the gates can never diverge
|
||||||
|
let tx
|
||||||
|
try {
|
||||||
|
tx = await buildGatedKyberTx({
|
||||||
|
routeSummary: data.routeSummary,
|
||||||
|
sender: user,
|
||||||
|
recipient: user,
|
||||||
|
slippageBps: slip,
|
||||||
|
amountIn: amount,
|
||||||
|
nativeIn: isNative(tIn.address),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
txlog.push('err', (e as Error).message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await step(t('swap.stSwapKyber', { amt: amtStr, a: tIn.symbol, b: tOut.symbol }), () =>
|
||||||
|
sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, value: tx.value, chainId: CHAIN_ID }),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (!nativeBest) return
|
||||||
|
if (isNative(tIn.address)) {
|
||||||
|
txlog.push('err', t('swap.errNeedsWeth'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const minOut = applySlippage(nativeBest.amountOut, slip)
|
||||||
|
if (nativeBest.kind === 'v2') {
|
||||||
|
if (!(await ensureAllowance(tIn.address, user, ADDR.V2_ROUTER, amount, tIn.symbol))) return
|
||||||
|
await step(t('swap.stSwapV2', { amt: amtStr, a: tIn.symbol, b: tOut.symbol }), () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: v2RouterAbi,
|
||||||
|
address: ADDR.V2_ROUTER,
|
||||||
|
functionName: 'swapExactTokensForTokens',
|
||||||
|
args: [
|
||||||
|
amount,
|
||||||
|
minOut,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
from: tIn.address,
|
||||||
|
to: erc20Of(tOut.address),
|
||||||
|
stable: nativeBest.pool.stable,
|
||||||
|
factory: ADDR.V2_FACTORY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
user,
|
||||||
|
deadline(),
|
||||||
|
],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (!(await ensureAllowance(tIn.address, user, ADDR.CL_SWAP_ROUTER, amount, tIn.symbol))) return
|
||||||
|
await step(
|
||||||
|
t('swap.stSwapCl', { amt: amtStr, a: tIn.symbol, b: tOut.symbol, ts: nativeBest.pool.tickSpacing }),
|
||||||
|
() =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: clSwapRouterAbi,
|
||||||
|
address: ADDR.CL_SWAP_ROUTER,
|
||||||
|
functionName: 'exactInputSingle',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
tokenIn: tIn.address,
|
||||||
|
tokenOut: erc20Of(tOut.address),
|
||||||
|
tickSpacing: nativeBest.pool.tickSpacing,
|
||||||
|
recipient: user,
|
||||||
|
deadline: deadline(),
|
||||||
|
amountIn: amount,
|
||||||
|
amountOutMinimum: minOut,
|
||||||
|
sqrtPriceLimitX96: 0n,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const modeRow = (
|
||||||
|
<div className="form-row" style={{ marginBottom: 10 }}>
|
||||||
|
<span className="lbl">{t('swap.mode')}</span>
|
||||||
|
<button className={`chip ${mode === 'market' ? 'on' : ''}`} onClick={() => setMode('market')}>
|
||||||
|
{t('swap.market')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`chip ${mode === 'limit' ? 'on' : ''}`}
|
||||||
|
onClick={() => setMode('limit')}
|
||||||
|
title={t('swap.limitTip')}
|
||||||
|
>
|
||||||
|
{t('swap.limit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (mode === 'limit')
|
||||||
|
return (
|
||||||
|
<div className="swap-box">
|
||||||
|
{modeRow}
|
||||||
|
<LimitPanel />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tIn || !tOut)
|
||||||
|
return (
|
||||||
|
<div className="swap-box">
|
||||||
|
{modeRow}
|
||||||
|
<div className="dim">{t('swap.loadingTokens')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const balIn = bal.data?.[tIn.address.toLowerCase()]
|
||||||
|
const balOut = bal.data?.[tOut.address.toLowerCase()]
|
||||||
|
const insufficient = balIn !== undefined && amount > balIn
|
||||||
|
|
||||||
|
const setMax = () => {
|
||||||
|
if (balIn === undefined) return
|
||||||
|
const v = tIn.native ? (balIn > ETH_GAS_BUFFER ? balIn - ETH_GAS_BUFFER : 0n) : balIn
|
||||||
|
setAmtStr(formatUnits(v, tIn.decimals))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate =
|
||||||
|
selOut !== undefined && amount > 0n
|
||||||
|
? Number(formatUnits(selOut, tOut.decimals)) / Number(formatUnits(amount, tIn.decimals))
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const usdIn = kyber.data?.routeSummary.amountInUsd
|
||||||
|
const usdOut = kyber.data?.routeSummary.amountOutUsd
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="swap-box">
|
||||||
|
{modeRow}
|
||||||
|
<div className="swap-side">
|
||||||
|
<div className="top">
|
||||||
|
<span className="side-lbl">{t('swap.from')}</span>
|
||||||
|
<TokenSelect list={list} value={tIn} exclude={tOut.address} onChange={setTIn} />
|
||||||
|
<input
|
||||||
|
className="amt"
|
||||||
|
inputMode="decimal"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="0.0"
|
||||||
|
value={amtStr}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.replace(',', '.')
|
||||||
|
if (v === '' || /^\d*\.?\d*$/.test(v)) setAmtStr(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="meta">
|
||||||
|
<span>
|
||||||
|
{t('common.bal')} {balIn !== undefined ? fmtAmount(balIn, tIn.decimals) : '—'}{' '}
|
||||||
|
<button className="chip" onClick={setMax}>
|
||||||
|
{t('common.max')}
|
||||||
|
</button>
|
||||||
|
{insufficient && <span className="red"> {t('common.insufficient')}</span>}
|
||||||
|
</span>
|
||||||
|
<span>{amount > 0n && usdIn ? `≈ ${fmtUsd(usdIn)}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="swap-mid">
|
||||||
|
<button className="chip" onClick={flip} title={t('swap.flipTip')}>
|
||||||
|
⇅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="swap-side">
|
||||||
|
<div className="top">
|
||||||
|
<span className="side-lbl">{t('swap.to')}</span>
|
||||||
|
<TokenSelect list={list} value={tOut} exclude={tIn.address} onChange={setTOut} />
|
||||||
|
<span className={`out ${selOut !== undefined ? '' : 'dim'}`}>
|
||||||
|
{isWrap || isUnwrap
|
||||||
|
? amtStr || '0.0'
|
||||||
|
: selOut !== undefined
|
||||||
|
? fmtAmount(selOut, tOut.decimals)
|
||||||
|
: kyber.isFetching || native.isFetching
|
||||||
|
? '…'
|
||||||
|
: '0.0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="meta">
|
||||||
|
<span>
|
||||||
|
{t('common.bal')} {balOut !== undefined ? fmtAmount(balOut, tOut.decimals) : '—'}
|
||||||
|
</span>
|
||||||
|
<span>{amount > 0n && usdOut && !isWrap && !isUnwrap ? `≈ ${fmtUsd(usdOut)}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rate !== undefined && !isWrap && !isUnwrap && (
|
||||||
|
<div className="rate-line" onClick={() => setInvRate(!invRate)} title={t('swap.rateTip')}>
|
||||||
|
{t('swap.rate', {
|
||||||
|
a: invRate ? tOut.symbol : tIn.symbol,
|
||||||
|
n: fmtNum(invRate ? 1 / rate : rate),
|
||||||
|
b: invRate ? tIn.symbol : tOut.symbol,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isWrap || isUnwrap ? (
|
||||||
|
<div className="form-row" style={{ marginTop: 12 }}>
|
||||||
|
<Btn big busy={busy} disabled={!user || amount === 0n || insufficient} onClick={isWrap ? doWrap : doUnwrap}>
|
||||||
|
{insufficient
|
||||||
|
? t('common.insufficientBalance')
|
||||||
|
: isWrap
|
||||||
|
? t('swap.wrapBtn', { amt: amtStr || '0' })
|
||||||
|
: t('swap.unwrapBtn', { amt: amtStr || '0' })}
|
||||||
|
</Btn>
|
||||||
|
<span className="dim mono-sm">{t('swap.wrapNote', { addr: shortAddr(ADDR.WETH) })}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="section-title">{t('swap.quotes')}</div>
|
||||||
|
{amount === 0n && <div className="dim">{t('swap.enterAmount')}</div>}
|
||||||
|
{amount > 0n && (
|
||||||
|
<>
|
||||||
|
<div className={`quote-row ${sel === 'kyber' ? 'sel' : ''}`} onClick={() => setOverride('kyber')}>
|
||||||
|
<span className="src">
|
||||||
|
{sel === 'kyber' ? '◉' : '○'} {t('swap.kyberAgg')}
|
||||||
|
</span>
|
||||||
|
{kyber.isFetching && !kyber.data ? (
|
||||||
|
<span className="spin">▮</span>
|
||||||
|
) : kyber.isError ? (
|
||||||
|
<span className="red mono-sm">
|
||||||
|
{t('swap.unavailable', { err: (kyber.error as Error)?.message?.slice(0, 60) })}
|
||||||
|
</span>
|
||||||
|
) : kyberOut !== undefined ? (
|
||||||
|
<>
|
||||||
|
<span className="out green">
|
||||||
|
{fmtAmount(kyberOut, tOut.decimals)} {tOut.symbol}
|
||||||
|
</span>
|
||||||
|
<span className="dim mono-sm">{t('swap.gas', { usd: fmtUsd(kyber.data!.routeSummary.gasUsd) || '—' })}</span>
|
||||||
|
<span className="dim mono-sm">{routeBreakdown(kyber.data!.routeSummary)}</span>
|
||||||
|
{auto === 'kyber' && <Badge tone="green">{t('swap.best')}</Badge>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="dim">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`quote-row ${sel === 'native' ? 'sel' : ''}`} onClick={() => setOverride('native')}>
|
||||||
|
<span className="src">
|
||||||
|
{sel === 'native' ? '◉' : '○'} {t('swap.up33Native')}
|
||||||
|
</span>
|
||||||
|
{native.isFetching && !native.data ? (
|
||||||
|
<span className="spin">▮</span>
|
||||||
|
) : nativeBest ? (
|
||||||
|
<>
|
||||||
|
<span className="out">
|
||||||
|
{fmtAmount(nativeBest.amountOut, tOut.decimals)} {tOut.symbol}
|
||||||
|
</span>
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{nativeBest.kind === 'v2'
|
||||||
|
? t('swap.viaV2', {
|
||||||
|
kind: nativeBest.pool.stable ? t('swap.stable') : t('swap.volatile'),
|
||||||
|
fee: (nativeBest.pool.feeBps / 100).toFixed(2),
|
||||||
|
})
|
||||||
|
: t('swap.viaCl', {
|
||||||
|
fee: (nativeBest.pool.feePpm / 10_000).toFixed(2),
|
||||||
|
ts: nativeBest.pool.tickSpacing,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{kyberOut !== undefined && (
|
||||||
|
<span className={bpsDiff(nativeBest.amountOut, kyberOut) < 0 ? 'red mono-sm' : 'green mono-sm'}>
|
||||||
|
{t('swap.bpsVsKyber', {
|
||||||
|
bps:
|
||||||
|
(bpsDiff(nativeBest.amountOut, kyberOut) >= 0 ? '+' : '') +
|
||||||
|
bpsDiff(nativeBest.amountOut, kyberOut).toFixed(1),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{auto === 'native' && <Badge tone="green">{t('swap.best')}</Badge>}
|
||||||
|
{isNative(tIn.address) && <span className="amber mono-sm">{t('swap.needsWeth')}</span>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="dim mono-sm">{t('swap.noNativePool')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row" style={{ marginTop: 10 }}>
|
||||||
|
<span className="lbl">{t('swap.slippage')}</span>
|
||||||
|
{[10, 50, 100].map((b) => (
|
||||||
|
<button key={b} className={`chip ${slip === b ? 'on' : ''}`} onClick={() => setSlip(b)}>
|
||||||
|
{b / 100}%
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="chip"
|
||||||
|
onClick={() => {
|
||||||
|
void kyber.refetch()
|
||||||
|
void native.refetch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('swap.refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<Btn
|
||||||
|
big
|
||||||
|
busy={busy}
|
||||||
|
disabled={
|
||||||
|
!user ||
|
||||||
|
amount === 0n ||
|
||||||
|
!sel ||
|
||||||
|
insufficient ||
|
||||||
|
(sel === 'native' && (isNative(tIn.address) || !nativeBest))
|
||||||
|
}
|
||||||
|
onClick={doSwap}
|
||||||
|
>
|
||||||
|
{!user
|
||||||
|
? t('common.connectWallet')
|
||||||
|
: insufficient
|
||||||
|
? t('common.insufficientBalance')
|
||||||
|
: sel
|
||||||
|
? t('swap.execVia', { route: sel === 'kyber' ? 'KYBER' : 'UP33' })
|
||||||
|
: t('swap.noRoute')}
|
||||||
|
</Btn>
|
||||||
|
{selOut !== undefined && (
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('swap.minReceived', {
|
||||||
|
amt: fmtAmount(applySlippage(selOut, slip), tOut.decimals),
|
||||||
|
sym: tOut.symbol,
|
||||||
|
slip: slip / 100,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{sel === 'native' && isNative(tOut.address) && (
|
||||||
|
<span className="amber mono-sm">{t('swap.nativeWethNote')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { formatUnits } from 'viem'
|
||||||
|
import { fmtAmount } from '../lib/format'
|
||||||
|
|
||||||
|
export function Btn(props: {
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
tone?: 'default' | 'danger' | 'ghost'
|
||||||
|
big?: boolean
|
||||||
|
busy?: boolean
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const cls = ['btn', props.tone === 'danger' ? 'danger' : '', props.tone === 'ghost' ? 'ghost' : '', props.big ? 'big' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
return (
|
||||||
|
<button className={cls} onClick={props.onClick} disabled={props.disabled || props.busy} title={props.title}>
|
||||||
|
{props.busy ? <span className="spin">▮</span> : props.children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge(props: { tone?: 'green' | 'amber' | 'red' | 'cyan' | 'dim'; children: ReactNode }) {
|
||||||
|
return <span className={`badge ${props.tone ?? ''}`}>{props.children}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stat(props: { k: string; v: ReactNode; sub?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="stat">
|
||||||
|
<div className="k">{props.k}</div>
|
||||||
|
<div className="v">{props.v}</div>
|
||||||
|
{props.sub !== undefined && <div className="sub">{props.sub}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** amount input row: label + NumInput + wallet balance + MAX chip */
|
||||||
|
export function AmountRow(props: {
|
||||||
|
sym: string
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
bal?: bigint
|
||||||
|
dec: number
|
||||||
|
onMax: (v: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
note?: string
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="form-row">
|
||||||
|
<span className="lbl">{props.sym}</span>
|
||||||
|
<NumInput value={props.value} onChange={props.onChange} disabled={props.disabled} width={220} />
|
||||||
|
{props.bal !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="dim mono-sm">
|
||||||
|
{t('common.bal')} {fmtAmount(props.bal, props.dec)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="chip"
|
||||||
|
onClick={() => props.onMax(formatUnits(props.bal!, props.dec))}
|
||||||
|
disabled={props.disabled}
|
||||||
|
>
|
||||||
|
{t('common.max')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{props.note && <span className="amber mono-sm">{props.note}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** numeric text input that only accepts decimal strings */
|
||||||
|
export function NumInput(props: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
width?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={props.width ? { width: props.width } : undefined}
|
||||||
|
inputMode="decimal"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={props.placeholder ?? '0.0'}
|
||||||
|
value={props.value}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.replace(',', '.')
|
||||||
|
if (v === '' || /^\d*\.?\d*$/.test(v)) props.onChange(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Address } from 'viem'
|
||||||
|
|
||||||
|
// All addresses verified against Blockscout's verified source (compiler 0.8.19).
|
||||||
|
export const ADDR = {
|
||||||
|
UP: '0x57C0E45cB534413D1C20A4240955d6bB250BB4F1',
|
||||||
|
VE_UP: '0x5d321dE36F0bf98D92b291280514F3878582B7B6',
|
||||||
|
VOTER: '0x7F749fDD351C1Ceed82d76d7699CB631Eb8332a7',
|
||||||
|
MINTER: '0x912EC7A90e8C9829eE0e0f6a4Db5270776Fc3Da5',
|
||||||
|
V2_FACTORY: '0xFA5429AEBa338BEa2BFcc1b9a889862Ee395bc28',
|
||||||
|
V2_ROUTER: '0xf5198743240fAC98db71868F34c70139b1eb0474',
|
||||||
|
CL_FACTORY: '0x1ac9dB4a2608ba45D6127B1737949b51Bb54B7F3',
|
||||||
|
CL_PM: '0x07F44c47743A2f36414A82b9F558ECFCf0EEdCEf',
|
||||||
|
CL_SWAP_ROUTER: '0xC062b870E813fcA720f1e002c234369Ab3aB9415',
|
||||||
|
CL_QUOTER: '0x03983AB2C057a2eac211ff01738a1e49ff325B49',
|
||||||
|
WETH: '0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73',
|
||||||
|
USDG: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168',
|
||||||
|
} as const satisfies Record<string, Address>
|
||||||
|
|
||||||
|
// Official Uniswap v2 + v3 on Robinhood Chain (developers.uniswap.org
|
||||||
|
// deployments; chain-verified 2026-07-16: NPM.factory() == V3_FACTORY,
|
||||||
|
// NPM.WETH9() == ADDR.WETH, Router02.factory() == V2_FACTORY, Router02.WETH()
|
||||||
|
// == ADDR.WETH — beware, Blockscout also lists several unofficial same-name
|
||||||
|
// forks). v3 write entrypoints are signature-identical to the Slipstream
|
||||||
|
// CL_PM, so clPmAbi fragments are reused with these addresses.
|
||||||
|
// v4 is also live (PoolManager 0x8366a39CC670B4001A1121B8F6A443A643e40951,
|
||||||
|
// PositionManager 0x58dAeC3116AaE6D93017bAaEA7749052e8a04FA7) — not integrated.
|
||||||
|
export const UNI = {
|
||||||
|
V3_FACTORY: '0x1f7d7550B1b028f7571E69A784071F0205FD2EfA',
|
||||||
|
V3_NPM: '0x73991a25C818Bf1f1128dEAaB1492D45638DE0D3',
|
||||||
|
V2_FACTORY: '0x8bcEaA40B9AcdfAedF85AdF4FF01F5Ad6517937f',
|
||||||
|
V2_ROUTER: '0x89e5DB8B5aA49aA85AC63f691524311AEB649eba',
|
||||||
|
} as const satisfies Record<string, Address>
|
||||||
|
|
||||||
|
export const EXPLORER = 'https://robinhoodchain.blockscout.com'
|
||||||
|
export const WEEK = 604800
|
||||||
|
export const CHAIN_ID = 4663
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineChain } from 'viem'
|
||||||
|
import { PUBLIC_RPC } from './env'
|
||||||
|
|
||||||
|
export const robinhood = defineChain({
|
||||||
|
id: 4663,
|
||||||
|
name: 'Robinhood Chain',
|
||||||
|
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||||
|
// chain METADATA always carries the key-free public RPC — this is what a
|
||||||
|
// wallet_addEthereumChain suggestion hands to users' wallets. The app's own
|
||||||
|
// reads go through the wagmi transport (see wagmi.ts), never this URL.
|
||||||
|
rpcUrls: { default: { http: [PUBLIC_RPC] } },
|
||||||
|
blockExplorers: {
|
||||||
|
default: { name: 'Blockscout', url: 'https://robinhoodchain.blockscout.com' },
|
||||||
|
},
|
||||||
|
contracts: {
|
||||||
|
multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' },
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getAddress, type Address } from 'viem'
|
||||||
|
|
||||||
|
/** chain-official public RPC — wallet-safe, key-free. Used for chain metadata
|
||||||
|
* (what gets suggested to wallets) and as the last-resort read transport. */
|
||||||
|
export const PUBLIC_RPC = 'https://rpc.mainnet.chain.robinhood.com'
|
||||||
|
|
||||||
|
// Values come from the repo-root .env via vite envDir/envPrefix (see vite.config.ts).
|
||||||
|
export const ENV = {
|
||||||
|
// Private RPC override (e.g. an Alchemy URL). SECRET when set — it is baked
|
||||||
|
// into the bundle, so only use it for personal/local builds. Public server
|
||||||
|
// builds must leave it unset: the app then reads through same-origin /rpc
|
||||||
|
// (nginx proxy keeps the key server-side) with PUBLIC_RPC as fallback.
|
||||||
|
rpcUrl: (import.meta.env.RPC ?? '').trim(),
|
||||||
|
// absolute URL (browser calls kyber direct) or a path like /kyber (same-origin
|
||||||
|
// reverse proxy — see README "Chain reads"): both work, fetch resolves relative URLs.
|
||||||
|
kyberBase: ((import.meta.env.KYBERSWAP_AGGREGATOR_API_BASE_URL ?? '').trim() ||
|
||||||
|
'https://aggregator-api.kyberswap.com').replace(/\/+$/, ''),
|
||||||
|
// same-origin proxy mode: when the kyber base is a path, ALL third-party data
|
||||||
|
// APIs (kyber settings, dexscreener, goldsky) route through the site's nginx
|
||||||
|
// proxies too — users behind restrictive networks keep every feature, and the
|
||||||
|
// browser only ever talks to our origin + the chain RPC + wallet relays.
|
||||||
|
get proxied() {
|
||||||
|
return this.kyberBase.startsWith('/')
|
||||||
|
},
|
||||||
|
kyberChain: (import.meta.env.KYBERSWAP_CHAIN ?? 'robinhood').trim(),
|
||||||
|
// Whitelisted swap target: the only address kyber calldata is ever sent to.
|
||||||
|
kyberRouter: getAddress(
|
||||||
|
(import.meta.env.KYBERSWAP_ROUTER_ADDRESS ?? '0x6131B5fae19EA4f9D964eAc0408E4408b66337b5').trim(),
|
||||||
|
) as Address,
|
||||||
|
// Optional. Injected wallets (MetaMask/Rabby/OKX…) work without it; only
|
||||||
|
// WalletConnect QR pairing needs a real project id.
|
||||||
|
wcProjectId: (import.meta.env.VITE_WALLETCONNECT_PROJECT_ID ?? '').trim() || 'up33-terminal-local',
|
||||||
|
// Optional platform fee on kyber swaps (verified working on this chain):
|
||||||
|
// both must be set to activate; fee is charged on the output token and sent
|
||||||
|
// to the receiver by the kyber router itself.
|
||||||
|
kyberFeeBps: Number((import.meta.env.KYBERSWAP_FEE_BPS ?? '').trim()) || 0,
|
||||||
|
kyberFeeReceiver: (import.meta.env.KYBERSWAP_FEE_RECEIVER ?? '').trim(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 8_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { getDefaultConfig } from '@rainbow-me/rainbowkit'
|
||||||
|
import { fallback, http } from 'wagmi'
|
||||||
|
import { customRpc } from '../lib/rpcPref'
|
||||||
|
import { robinhood } from './chain'
|
||||||
|
import { ENV, PUBLIC_RPC } from './env'
|
||||||
|
|
||||||
|
// Read-transport resolution (one build works in every deployment):
|
||||||
|
// - user-set custom RPC (footer control, localStorage) -> always wins
|
||||||
|
// - RPC set in .env (personal/local build) -> use it directly (secret stays local)
|
||||||
|
// - production build without RPC (server) -> same-origin /rpc proxy (nginx keeps
|
||||||
|
// the key server-side), falling back to the public RPC when no proxy exists
|
||||||
|
// (plain static hosting)
|
||||||
|
// - dev without RPC -> public RPC
|
||||||
|
const userRpc = customRpc()
|
||||||
|
const transport = userRpc
|
||||||
|
? http(userRpc, { batch: true })
|
||||||
|
: ENV.rpcUrl
|
||||||
|
? http(ENV.rpcUrl, { batch: true })
|
||||||
|
: import.meta.env.PROD
|
||||||
|
? fallback([http('/rpc', { batch: true }), http(PUBLIC_RPC, { batch: true })])
|
||||||
|
: http(PUBLIC_RPC, { batch: true })
|
||||||
|
|
||||||
|
export const wagmiConfig = getDefaultConfig({
|
||||||
|
appName: 'UP33 Terminal',
|
||||||
|
projectId: ENV.wcProjectId,
|
||||||
|
chains: [robinhood],
|
||||||
|
transports: { [robinhood.id]: transport },
|
||||||
|
ssr: false,
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { usePublicClient } from 'wagmi'
|
||||||
|
import type { Address, PublicClient } from 'viem'
|
||||||
|
import { erc20Abi } from '../abi'
|
||||||
|
import { NATIVE } from '../lib/kyber'
|
||||||
|
|
||||||
|
/** balances for a set of tokens (NATIVE sentinel included). key = lowercase addr */
|
||||||
|
export function useBalances(user?: Address, tokens: Address[] = []) {
|
||||||
|
const pc = usePublicClient()
|
||||||
|
const key = tokens
|
||||||
|
.map((t) => t.toLowerCase())
|
||||||
|
.sort()
|
||||||
|
.join(',')
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['balances', user, key],
|
||||||
|
enabled: !!pc && !!user && tokens.length > 0,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const client = pc as PublicClient
|
||||||
|
const out: Record<string, bigint> = {}
|
||||||
|
const erc20s = tokens.filter((t) => t.toLowerCase() !== NATIVE.toLowerCase())
|
||||||
|
const hasNative = tokens.length !== erc20s.length
|
||||||
|
const res = (await client.multicall({
|
||||||
|
contracts: erc20s.map((t) => ({
|
||||||
|
abi: erc20Abi,
|
||||||
|
address: t,
|
||||||
|
functionName: 'balanceOf',
|
||||||
|
args: [user!],
|
||||||
|
})) as never,
|
||||||
|
})) as { status: string; result?: unknown }[]
|
||||||
|
erc20s.forEach((t, i) => {
|
||||||
|
out[t.toLowerCase()] =
|
||||||
|
res[i]?.status === 'success' ? (res[i].result as bigint) : 0n
|
||||||
|
})
|
||||||
|
if (hasNative) out[NATIVE.toLowerCase()] = await client.getBalance({ address: user! })
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { WEEK } from '../config/addresses'
|
||||||
|
|
||||||
|
/** epoch flips every Thursday 00:00 UTC (unix weeks) */
|
||||||
|
export function useEpoch() {
|
||||||
|
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000))
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
const epochStart = Math.floor(now / WEEK) * WEEK
|
||||||
|
const nextFlip = epochStart + WEEK
|
||||||
|
const secsLeft = nextFlip - now
|
||||||
|
return { now, epochStart, nextFlip, secsLeft }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { usePublicClient } from 'wagmi'
|
||||||
|
import type { Address, PublicClient } from 'viem'
|
||||||
|
import { clPoolAbi } from '../abi'
|
||||||
|
|
||||||
|
export type LiveSlot0 = { sqrtPriceX96: bigint; tick: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast, targeted price feed: polls ONLY slot0 of the given CL pools (one
|
||||||
|
* multicall) every `intervalMs`. Used where fill/price must feel live (range
|
||||||
|
* orders) without re-running the full pool enumeration at that rate.
|
||||||
|
*/
|
||||||
|
export function useLiveSlot0(pools: Address[], intervalMs = 4_000) {
|
||||||
|
const pc = usePublicClient()
|
||||||
|
const key = pools
|
||||||
|
.map((a) => a.toLowerCase())
|
||||||
|
.sort()
|
||||||
|
.join(',')
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['liveSlot0', key],
|
||||||
|
enabled: !!pc && pools.length > 0,
|
||||||
|
refetchInterval: intervalMs,
|
||||||
|
staleTime: 0,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = (await (pc as PublicClient).multicall({
|
||||||
|
contracts: pools.map((a) => ({
|
||||||
|
abi: clPoolAbi,
|
||||||
|
address: a,
|
||||||
|
functionName: 'slot0',
|
||||||
|
})) as never,
|
||||||
|
})) as { status: string; result?: readonly [bigint, number, ...unknown[]] }[]
|
||||||
|
const out: Record<string, LiveSlot0> = {}
|
||||||
|
pools.forEach((a, i) => {
|
||||||
|
const r = res[i]
|
||||||
|
if (r?.status === 'success' && r.result) {
|
||||||
|
out[a.toLowerCase()] = { sqrtPriceX96: r.result[0], tick: Number(r.result[1]) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchPoolStats } from '../lib/poolstats'
|
||||||
|
import { usePools } from './usePools'
|
||||||
|
|
||||||
|
/** 24h volume / liquidity USD per pool (dexscreener + official v2 subgraph) */
|
||||||
|
export function usePoolStats() {
|
||||||
|
const pools = usePools()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['poolStats'],
|
||||||
|
enabled: !!pools.data,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 45_000,
|
||||||
|
retry: 1,
|
||||||
|
queryFn: () => fetchPoolStats(pools.data!.pools),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { usePublicClient } from 'wagmi'
|
||||||
|
import { zeroAddress, type Address, type PublicClient } from 'viem'
|
||||||
|
import {
|
||||||
|
clFactoryAbi,
|
||||||
|
clGaugeAbi,
|
||||||
|
clPoolAbi,
|
||||||
|
erc20Abi,
|
||||||
|
minterAbi,
|
||||||
|
v2FactoryAbi,
|
||||||
|
v2GaugeAbi,
|
||||||
|
v2PoolAbi,
|
||||||
|
voterAbi,
|
||||||
|
} from '../abi'
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import type { ClPool, Pool, PoolsData, TokenInfo, V2Pool } from '../types'
|
||||||
|
|
||||||
|
type McRes = { status: 'success' | 'failure'; result?: unknown; error?: Error }
|
||||||
|
|
||||||
|
async function mc(pc: PublicClient, contracts: unknown[]): Promise<McRes[]> {
|
||||||
|
if (contracts.length === 0) return []
|
||||||
|
return (await pc.multicall({ contracts: contracts as never })) as McRes[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok<T>(r: McRes | undefined): T | undefined {
|
||||||
|
return r && r.status === 'success' ? (r.result as T) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = (n: number) => Array.from({ length: n }, (_, i) => i)
|
||||||
|
|
||||||
|
// ---- token metadata cache (localStorage, shared with the univ3 browser) ----
|
||||||
|
const TOKEN_CACHE_KEY = 'up33:tokens:v2'
|
||||||
|
|
||||||
|
export function loadTokenCache(): Record<string, TokenInfo> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(TOKEN_CACHE_KEY) ?? '{}')
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function saveTokenCache(cache: Record<string, TokenInfo>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOKEN_CACHE_KEY, JSON.stringify(cache))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPools(pc: PublicClient): Promise<PoolsData> {
|
||||||
|
const head = await mc(pc, [
|
||||||
|
{ abi: v2FactoryAbi, address: ADDR.V2_FACTORY, functionName: 'allPoolsLength' },
|
||||||
|
{ abi: clFactoryAbi, address: ADDR.CL_FACTORY, functionName: 'allPoolsLength' },
|
||||||
|
{ abi: minterAbi, address: ADDR.MINTER, functionName: 'weekly' },
|
||||||
|
{ abi: minterAbi, address: ADDR.MINTER, functionName: 'epochCount' },
|
||||||
|
{ abi: minterAbi, address: ADDR.MINTER, functionName: 'activePeriod' },
|
||||||
|
{ abi: voterAbi, address: ADDR.VOTER, functionName: 'totalWeight' },
|
||||||
|
{ abi: voterAbi, address: ADDR.VOTER, functionName: 'capMode' },
|
||||||
|
])
|
||||||
|
const blockNumber = await pc.getBlockNumber()
|
||||||
|
|
||||||
|
const v2N = Math.min(Number(ok<bigint>(head[0]) ?? 0n), 300)
|
||||||
|
const clN = Math.min(Number(ok<bigint>(head[1]) ?? 0n), 600)
|
||||||
|
|
||||||
|
const addrRes = await mc(pc, [
|
||||||
|
...range(v2N).map((i) => ({
|
||||||
|
abi: v2FactoryAbi,
|
||||||
|
address: ADDR.V2_FACTORY,
|
||||||
|
functionName: 'allPools',
|
||||||
|
args: [BigInt(i)],
|
||||||
|
})),
|
||||||
|
...range(clN).map((i) => ({
|
||||||
|
abi: clFactoryAbi,
|
||||||
|
address: ADDR.CL_FACTORY,
|
||||||
|
functionName: 'allPools',
|
||||||
|
args: [BigInt(i)],
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
const v2Addrs = addrRes.slice(0, v2N).map((r) => ok<Address>(r)).filter(Boolean) as Address[]
|
||||||
|
const clAddrs = addrRes.slice(v2N).map((r) => ok<Address>(r)).filter(Boolean) as Address[]
|
||||||
|
|
||||||
|
// ---- per-pool detail ----
|
||||||
|
const detail: unknown[] = []
|
||||||
|
for (const p of v2Addrs) {
|
||||||
|
detail.push(
|
||||||
|
{ abi: v2PoolAbi, address: p, functionName: 'metadata' },
|
||||||
|
{ abi: v2PoolAbi, address: p, functionName: 'totalSupply' },
|
||||||
|
{ abi: voterAbi, address: ADDR.VOTER, functionName: 'gauges', args: [p] },
|
||||||
|
{ abi: voterAbi, address: ADDR.VOTER, functionName: 'weights', args: [p] },
|
||||||
|
{ abi: v2FactoryAbi, address: ADDR.V2_FACTORY, functionName: 'getFee', args: [p, false] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const p of clAddrs) {
|
||||||
|
detail.push(
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'slot0' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'liquidity' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'stakedLiquidity' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'fee' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'unstakedFee' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'tickSpacing' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'token0' },
|
||||||
|
{ abi: clPoolAbi, address: p, functionName: 'token1' },
|
||||||
|
{ abi: voterAbi, address: ADDR.VOTER, functionName: 'gauges', args: [p] },
|
||||||
|
{ abi: voterAbi, address: ADDR.VOTER, functionName: 'weights', args: [p] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const det = await mc(pc, detail)
|
||||||
|
|
||||||
|
const v2Pools: V2Pool[] = []
|
||||||
|
let i = 0
|
||||||
|
for (const p of v2Addrs) {
|
||||||
|
const md = ok<readonly [bigint, bigint, bigint, bigint, boolean, Address, Address]>(det[i])
|
||||||
|
const totalSupply = ok<bigint>(det[i + 1]) ?? 0n
|
||||||
|
const gauge = ok<Address>(det[i + 2])
|
||||||
|
const weight = ok<bigint>(det[i + 3]) ?? 0n
|
||||||
|
i += 5
|
||||||
|
if (!md) continue
|
||||||
|
v2Pools.push({
|
||||||
|
kind: 'v2',
|
||||||
|
protocol: 'up33',
|
||||||
|
address: p,
|
||||||
|
token0: md[5],
|
||||||
|
token1: md[6],
|
||||||
|
stable: md[4],
|
||||||
|
reserve0: md[2],
|
||||||
|
reserve1: md[3],
|
||||||
|
totalSupply,
|
||||||
|
gaugeTotalSupply: 0n, // filled in pass 2
|
||||||
|
feeBps: 0, // filled below with the stable-aware getFee
|
||||||
|
gauge: gauge && gauge !== zeroAddress ? gauge : null,
|
||||||
|
gaugeAlive: false,
|
||||||
|
weight,
|
||||||
|
rewardRate: 0n,
|
||||||
|
periodFinish: 0n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const clPools: ClPool[] = []
|
||||||
|
for (const p of clAddrs) {
|
||||||
|
const s0 = ok<readonly [bigint, number, number, number, number, boolean]>(det[i])
|
||||||
|
const liquidity = ok<bigint>(det[i + 1]) ?? 0n
|
||||||
|
const stakedLiquidity = ok<bigint>(det[i + 2]) ?? 0n
|
||||||
|
const fee = ok<number>(det[i + 3]) ?? 0
|
||||||
|
const unstakedFee = ok<number>(det[i + 4]) ?? 0
|
||||||
|
const tickSpacing = ok<number>(det[i + 5]) ?? 0
|
||||||
|
const token0 = ok<Address>(det[i + 6])
|
||||||
|
const token1 = ok<Address>(det[i + 7])
|
||||||
|
const gauge = ok<Address>(det[i + 8])
|
||||||
|
const weight = ok<bigint>(det[i + 9]) ?? 0n
|
||||||
|
i += 10
|
||||||
|
if (!s0 || !token0 || !token1) continue
|
||||||
|
clPools.push({
|
||||||
|
kind: 'cl',
|
||||||
|
protocol: 'up33',
|
||||||
|
address: p,
|
||||||
|
token0,
|
||||||
|
token1,
|
||||||
|
tickSpacing,
|
||||||
|
feePpm: fee,
|
||||||
|
unstakedFeePpm: unstakedFee,
|
||||||
|
sqrtPriceX96: s0[0],
|
||||||
|
tick: s0[1],
|
||||||
|
liquidity,
|
||||||
|
stakedLiquidity,
|
||||||
|
gauge: gauge && gauge !== zeroAddress ? gauge : null,
|
||||||
|
gaugeAlive: false,
|
||||||
|
weight,
|
||||||
|
rewardRate: 0n,
|
||||||
|
periodFinish: 0n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- second pass: stable-aware v2 fee + gauge liveness/rates ----
|
||||||
|
const pass2: unknown[] = []
|
||||||
|
const pass2Tag: { kind: string; idx: number }[] = []
|
||||||
|
v2Pools.forEach((p, idx) => {
|
||||||
|
pass2.push({
|
||||||
|
abi: v2FactoryAbi,
|
||||||
|
address: ADDR.V2_FACTORY,
|
||||||
|
functionName: 'getFee',
|
||||||
|
args: [p.address, p.stable],
|
||||||
|
})
|
||||||
|
pass2Tag.push({ kind: 'v2fee', idx })
|
||||||
|
})
|
||||||
|
const allPools: Pool[] = [...v2Pools, ...clPools]
|
||||||
|
allPools.forEach((p, idx) => {
|
||||||
|
if (!p.gauge) return
|
||||||
|
const gaugeAbi = p.kind === 'v2' ? v2GaugeAbi : clGaugeAbi
|
||||||
|
pass2.push({ abi: voterAbi, address: ADDR.VOTER, functionName: 'isAlive', args: [p.gauge] })
|
||||||
|
pass2Tag.push({ kind: 'alive', idx })
|
||||||
|
pass2.push({ abi: gaugeAbi, address: p.gauge, functionName: 'rewardRate' })
|
||||||
|
pass2Tag.push({ kind: 'rate', idx })
|
||||||
|
pass2.push({ abi: gaugeAbi, address: p.gauge, functionName: 'periodFinish' })
|
||||||
|
pass2Tag.push({ kind: 'finish', idx })
|
||||||
|
if (p.kind === 'v2') {
|
||||||
|
pass2.push({ abi: v2GaugeAbi, address: p.gauge, functionName: 'totalSupply' })
|
||||||
|
pass2Tag.push({ kind: 'gts', idx })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const p2 = await mc(pc, pass2)
|
||||||
|
p2.forEach((r, j) => {
|
||||||
|
const tag = pass2Tag[j]
|
||||||
|
if (tag.kind === 'v2fee') {
|
||||||
|
const fee = ok<bigint>(r)
|
||||||
|
if (fee !== undefined) (v2Pools[tag.idx] as V2Pool).feeBps = Number(fee)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pool = allPools[tag.idx]
|
||||||
|
if (tag.kind === 'alive') pool.gaugeAlive = ok<boolean>(r) ?? false
|
||||||
|
if (tag.kind === 'rate') pool.rewardRate = ok<bigint>(r) ?? 0n
|
||||||
|
if (tag.kind === 'finish') pool.periodFinish = ok<bigint>(r) ?? 0n
|
||||||
|
if (tag.kind === 'gts' && pool.kind === 'v2') pool.gaugeTotalSupply = ok<bigint>(r) ?? 0n
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- token metadata ----
|
||||||
|
const cache = loadTokenCache()
|
||||||
|
const tokens: Record<string, TokenInfo> = {}
|
||||||
|
const missing: Address[] = []
|
||||||
|
const uniq = new Set<string>()
|
||||||
|
for (const p of allPools) {
|
||||||
|
for (const t of [p.token0, p.token1]) {
|
||||||
|
const k = t.toLowerCase()
|
||||||
|
if (uniq.has(k)) continue
|
||||||
|
uniq.add(k)
|
||||||
|
if (cache[k]) tokens[k] = cache[k]
|
||||||
|
else missing.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length) {
|
||||||
|
const metaRes = await mc(
|
||||||
|
pc,
|
||||||
|
missing.flatMap((t) => [
|
||||||
|
{ abi: erc20Abi, address: t, functionName: 'symbol' },
|
||||||
|
{ abi: erc20Abi, address: t, functionName: 'decimals' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
missing.forEach((t, j) => {
|
||||||
|
const symbol = ok<string>(metaRes[j * 2]) ?? t.slice(0, 6) + '…'
|
||||||
|
const decimals = ok<number>(metaRes[j * 2 + 1]) ?? 18
|
||||||
|
const info: TokenInfo = { address: t, symbol, decimals }
|
||||||
|
tokens[t.toLowerCase()] = info
|
||||||
|
cache[t.toLowerCase()] = info
|
||||||
|
})
|
||||||
|
saveTokenCache(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort: gauged & emitting first (by vote weight), then by kind
|
||||||
|
allPools.sort((a, b) => {
|
||||||
|
const aw = a.weight
|
||||||
|
const bw = b.weight
|
||||||
|
if (aw !== bw) return bw > aw ? 1 : -1
|
||||||
|
return a.kind === b.kind ? 0 : a.kind === 'cl' ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
pools: allPools,
|
||||||
|
tokens,
|
||||||
|
protocol: {
|
||||||
|
weekly: ok<bigint>(head[2]) ?? 0n,
|
||||||
|
epochCount: Number(ok<bigint>(head[3]) ?? 0n),
|
||||||
|
activePeriod: Number(ok<bigint>(head[4]) ?? 0n),
|
||||||
|
totalWeight: ok<bigint>(head[5]) ?? 0n,
|
||||||
|
capMode: ok<number>(head[6]) ?? null,
|
||||||
|
blockNumber,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePools() {
|
||||||
|
const pc = usePublicClient()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['pools'],
|
||||||
|
enabled: !!pc,
|
||||||
|
refetchInterval: 20_000,
|
||||||
|
queryFn: () => fetchPools(pc as PublicClient),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenOf(data: PoolsData | undefined, addr: Address | string): TokenInfo {
|
||||||
|
const k = addr.toLowerCase()
|
||||||
|
return (
|
||||||
|
data?.tokens[k] ?? {
|
||||||
|
address: addr as Address,
|
||||||
|
symbol: addr.slice(0, 6) + '…',
|
||||||
|
decimals: 18,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poolLabel(data: PoolsData | undefined, p: Pool): string {
|
||||||
|
const t0 = tokenOf(data, p.token0).symbol
|
||||||
|
const t1 = tokenOf(data, p.token1).symbol
|
||||||
|
return `${t0}/${t1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poolTypeLabel(p: Pool): string {
|
||||||
|
if (p.kind === 'v2') {
|
||||||
|
if (p.protocol === 'univ2') return 'uniswap v2'
|
||||||
|
return p.stable ? 'v2 STABLE' : 'v2 VOLATILE'
|
||||||
|
}
|
||||||
|
if (p.protocol === 'univ3') return `uniswap v3 ts${p.tickSpacing}`
|
||||||
|
return `CL ts${p.tickSpacing}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poolFeePct(p: Pool): number {
|
||||||
|
return p.kind === 'v2' ? p.feeBps / 100 : p.feePpm / 10_000
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { usePublicClient } from 'wagmi'
|
||||||
|
import type { Address, PublicClient } from 'viem'
|
||||||
|
import { clGaugeAbi, clPmAbi, erc20Abi, uniV3FactoryAbi, uniV3PmAbi, uniV3PoolAbi, v2GaugeAbi, v2PoolAbi } from '../abi'
|
||||||
|
import { ADDR, UNI } from '../config/addresses'
|
||||||
|
import { MAX_UINT128, getAmountsForLiquidity, getSqrtRatioAtTick } from '../lib/clmath'
|
||||||
|
import type { ClPool, ClPosition, PoolsData, PositionsData, TokenInfo, V2Pool, V2Position } from '../types'
|
||||||
|
import { usePools } from './usePools'
|
||||||
|
|
||||||
|
type McRes = { status: 'success' | 'failure'; result?: unknown }
|
||||||
|
async function mc(pc: PublicClient, contracts: unknown[]): Promise<McRes[]> {
|
||||||
|
if (contracts.length === 0) return []
|
||||||
|
return (await pc.multicall({ contracts: contracts as never })) as McRes[]
|
||||||
|
}
|
||||||
|
function ok<T>(r: McRes | undefined): T | undefined {
|
||||||
|
return r && r.status === 'success' ? (r.result as T) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawPos = readonly [
|
||||||
|
bigint, // nonce
|
||||||
|
Address, // operator
|
||||||
|
Address, // token0
|
||||||
|
Address, // token1
|
||||||
|
number, // tickSpacing
|
||||||
|
number, // tickLower
|
||||||
|
number, // tickUpper
|
||||||
|
bigint, // liquidity
|
||||||
|
bigint,
|
||||||
|
bigint,
|
||||||
|
bigint, // tokensOwed0
|
||||||
|
bigint, // tokensOwed1
|
||||||
|
]
|
||||||
|
|
||||||
|
// same tuple shape as RawPos, but index 4 is the uint24 fee tier (univ3 NPMs
|
||||||
|
// are fee-keyed where Slipstream is tickSpacing-keyed)
|
||||||
|
type RawUniPos = RawPos
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniswap v3 wallet positions (official Robinhood Chain deployment). Pools are
|
||||||
|
* discovered per position via factory.getPool and read fresh (slot0/liquidity/
|
||||||
|
* tickSpacing); tokens outside the UP33 registry get erc20 metadata fetched so
|
||||||
|
* any pair renders correctly. No gauges here — positions are never staked.
|
||||||
|
*/
|
||||||
|
async function fetchUniPositions(
|
||||||
|
pc: PublicClient,
|
||||||
|
user: Address,
|
||||||
|
pools: PoolsData,
|
||||||
|
): Promise<{ cl: ClPosition[]; tokens: Record<string, TokenInfo> }> {
|
||||||
|
const none = { cl: [], tokens: {} }
|
||||||
|
const cntRes = await mc(pc, [
|
||||||
|
{ abi: uniV3PmAbi, address: UNI.V3_NPM, functionName: 'balanceOf', args: [user] },
|
||||||
|
])
|
||||||
|
const count = Number(ok<bigint>(cntRes[0]) ?? 0n)
|
||||||
|
if (count === 0) return none
|
||||||
|
|
||||||
|
const idRes = await mc(
|
||||||
|
pc,
|
||||||
|
Array.from({ length: Math.min(count, 100) }, (_, i) => ({
|
||||||
|
abi: uniV3PmAbi,
|
||||||
|
address: UNI.V3_NPM,
|
||||||
|
functionName: 'tokenOfOwnerByIndex',
|
||||||
|
args: [user, BigInt(i)],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const ids = idRes.map((r) => ok<bigint>(r)).filter((x): x is bigint => x !== undefined)
|
||||||
|
if (ids.length === 0) return none
|
||||||
|
|
||||||
|
const posRes = await mc(
|
||||||
|
pc,
|
||||||
|
ids.map((id) => ({ abi: uniV3PmAbi, address: UNI.V3_NPM, functionName: 'positions', args: [id] })),
|
||||||
|
)
|
||||||
|
const raws = ids
|
||||||
|
.map((id, j) => ({ id, raw: ok<RawUniPos>(posRes[j]) }))
|
||||||
|
.filter((x): x is { id: bigint; raw: RawUniPos } => !!x.raw)
|
||||||
|
// drop empty NFTs (closed positions linger in wallets)
|
||||||
|
.filter(({ raw }) => raw[7] > 0n || raw[10] > 0n || raw[11] > 0n)
|
||||||
|
if (raws.length === 0) return none
|
||||||
|
|
||||||
|
// resolve each distinct (token0, token1, fee) to its pool address
|
||||||
|
const poolKeys = new Map<string, { token0: Address; token1: Address; fee: number }>()
|
||||||
|
for (const { raw } of raws) {
|
||||||
|
poolKeys.set(`${raw[2].toLowerCase()}|${raw[3].toLowerCase()}|${raw[4]}`, {
|
||||||
|
token0: raw[2],
|
||||||
|
token1: raw[3],
|
||||||
|
fee: raw[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const keys = [...poolKeys.entries()]
|
||||||
|
const addrRes = await mc(
|
||||||
|
pc,
|
||||||
|
keys.map(([, k]) => ({
|
||||||
|
abi: uniV3FactoryAbi,
|
||||||
|
address: UNI.V3_FACTORY,
|
||||||
|
functionName: 'getPool',
|
||||||
|
args: [k.token0, k.token1, k.fee],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// pool state + erc20 metadata for tokens the UP33 registry doesn't know
|
||||||
|
const unknownTokens = new Set<string>()
|
||||||
|
for (const [, k] of keys) {
|
||||||
|
for (const t of [k.token0, k.token1]) {
|
||||||
|
if (!pools.tokens[t.toLowerCase()]) unknownTokens.add(t.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tokenList = [...unknownTokens] as Address[]
|
||||||
|
const poolAddrs = keys.map(([, ], i) => ok<Address>(addrRes[i]))
|
||||||
|
const stateCalls: unknown[] = poolAddrs.flatMap((a) =>
|
||||||
|
a
|
||||||
|
? [
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'slot0' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'liquidity' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'tickSpacing' },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
const metaCalls: unknown[] = tokenList.flatMap((t) => [
|
||||||
|
{ abi: erc20Abi, address: t, functionName: 'symbol' },
|
||||||
|
{ abi: erc20Abi, address: t, functionName: 'decimals' },
|
||||||
|
])
|
||||||
|
const r = await mc(pc, [...stateCalls, ...metaCalls])
|
||||||
|
|
||||||
|
const poolByKey = new Map<string, ClPool>()
|
||||||
|
let ri = 0
|
||||||
|
keys.forEach(([key, k], i) => {
|
||||||
|
const address = poolAddrs[i]
|
||||||
|
if (!address) return
|
||||||
|
const s0 = ok<readonly [bigint, number, number, number, number, number, boolean]>(r[ri])
|
||||||
|
const liquidity = ok<bigint>(r[ri + 1]) ?? 0n
|
||||||
|
const tickSpacing = ok<number>(r[ri + 2]) ?? 0
|
||||||
|
ri += 3
|
||||||
|
if (!s0) return
|
||||||
|
poolByKey.set(key, {
|
||||||
|
kind: 'cl',
|
||||||
|
protocol: 'univ3',
|
||||||
|
address,
|
||||||
|
token0: k.token0,
|
||||||
|
token1: k.token1,
|
||||||
|
tickSpacing,
|
||||||
|
feePpm: k.fee, // univ3 fee unit (hundredths of a bip) == ppm
|
||||||
|
unstakedFeePpm: 0,
|
||||||
|
sqrtPriceX96: s0[0],
|
||||||
|
tick: s0[1],
|
||||||
|
liquidity,
|
||||||
|
stakedLiquidity: 0n,
|
||||||
|
gauge: null,
|
||||||
|
gaugeAlive: false,
|
||||||
|
weight: 0n,
|
||||||
|
rewardRate: 0n,
|
||||||
|
periodFinish: 0n,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const tokens: Record<string, TokenInfo> = {}
|
||||||
|
tokenList.forEach((t, i) => {
|
||||||
|
const base = stateCalls.length + i * 2
|
||||||
|
tokens[t.toLowerCase()] = {
|
||||||
|
address: t,
|
||||||
|
symbol: ok<string>(r[base]) ?? t.slice(0, 6) + '…',
|
||||||
|
decimals: ok<number>(r[base + 1]) ?? 18,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cl: ClPosition[] = []
|
||||||
|
for (const { id, raw } of raws) {
|
||||||
|
const pool = poolByKey.get(`${raw[2].toLowerCase()}|${raw[3].toLowerCase()}|${raw[4]}`)
|
||||||
|
if (!pool) continue
|
||||||
|
const { amount0, amount1 } = getAmountsForLiquidity(
|
||||||
|
pool.sqrtPriceX96,
|
||||||
|
getSqrtRatioAtTick(raw[5]),
|
||||||
|
getSqrtRatioAtTick(raw[6]),
|
||||||
|
raw[7],
|
||||||
|
)
|
||||||
|
cl.push({
|
||||||
|
tokenId: id,
|
||||||
|
pool,
|
||||||
|
tickLower: raw[5],
|
||||||
|
tickUpper: raw[6],
|
||||||
|
liquidity: raw[7],
|
||||||
|
staked: false,
|
||||||
|
amount0,
|
||||||
|
amount1,
|
||||||
|
fees0: raw[10],
|
||||||
|
fees1: raw[11],
|
||||||
|
earned: 0n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { cl, tokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPositions(
|
||||||
|
pc: PublicClient,
|
||||||
|
user: Address,
|
||||||
|
pools: PoolsData,
|
||||||
|
): Promise<PositionsData> {
|
||||||
|
// univ3 discovery runs concurrently with the UP33 passes below
|
||||||
|
const uniP = fetchUniPositions(pc, user, pools).catch(() => ({ cl: [], tokens: {} }))
|
||||||
|
const clPools = pools.pools.filter((p): p is ClPool => p.kind === 'cl')
|
||||||
|
const v2Pools = pools.pools.filter((p): p is V2Pool => p.kind === 'v2')
|
||||||
|
const clGauges = clPools.filter((p) => p.gauge)
|
||||||
|
|
||||||
|
// pass 1: counts + per-pool balances
|
||||||
|
const pass1: unknown[] = [
|
||||||
|
{ abi: clPmAbi, address: ADDR.CL_PM, functionName: 'balanceOf', args: [user] },
|
||||||
|
...clGauges.map((p) => ({
|
||||||
|
abi: clGaugeAbi,
|
||||||
|
address: p.gauge!,
|
||||||
|
functionName: 'stakedValues',
|
||||||
|
args: [user],
|
||||||
|
})),
|
||||||
|
...v2Pools.flatMap((p) => [
|
||||||
|
{ abi: v2PoolAbi, address: p.address, functionName: 'balanceOf', args: [user] },
|
||||||
|
{ abi: v2PoolAbi, address: p.address, functionName: 'claimable0', args: [user] },
|
||||||
|
{ abi: v2PoolAbi, address: p.address, functionName: 'claimable1', args: [user] },
|
||||||
|
...(p.gauge
|
||||||
|
? [
|
||||||
|
{ abi: v2GaugeAbi, address: p.gauge, functionName: 'balanceOf', args: [user] },
|
||||||
|
{ abi: v2GaugeAbi, address: p.gauge, functionName: 'earned', args: [user] },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
const r1 = await mc(pc, pass1)
|
||||||
|
let idx = 0
|
||||||
|
const walletCount = Number(ok<bigint>(r1[idx++]) ?? 0n)
|
||||||
|
const stakedIdsByGauge: { pool: ClPool; ids: bigint[] }[] = []
|
||||||
|
for (const p of clGauges) {
|
||||||
|
const ids = ok<readonly bigint[]>(r1[idx++]) ?? []
|
||||||
|
if (ids.length) stakedIdsByGauge.push({ pool: p, ids: [...ids] })
|
||||||
|
}
|
||||||
|
const v2Raw: {
|
||||||
|
pool: V2Pool
|
||||||
|
walletLp: bigint
|
||||||
|
claimable0: bigint
|
||||||
|
claimable1: bigint
|
||||||
|
stakedLp: bigint
|
||||||
|
earned: bigint
|
||||||
|
}[] = []
|
||||||
|
for (const p of v2Pools) {
|
||||||
|
const walletLp = ok<bigint>(r1[idx++]) ?? 0n
|
||||||
|
const claimable0 = ok<bigint>(r1[idx++]) ?? 0n
|
||||||
|
const claimable1 = ok<bigint>(r1[idx++]) ?? 0n
|
||||||
|
let stakedLp = 0n
|
||||||
|
let earned = 0n
|
||||||
|
if (p.gauge) {
|
||||||
|
stakedLp = ok<bigint>(r1[idx++]) ?? 0n
|
||||||
|
earned = ok<bigint>(r1[idx++]) ?? 0n
|
||||||
|
}
|
||||||
|
if (walletLp > 0n || stakedLp > 0n || claimable0 > 0n || claimable1 > 0n || earned > 0n) {
|
||||||
|
v2Raw.push({ pool: p, walletLp, claimable0, claimable1, stakedLp, earned })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pass 2: wallet tokenIds
|
||||||
|
const idRes = await mc(
|
||||||
|
pc,
|
||||||
|
Array.from({ length: Math.min(walletCount, 100) }, (_, i) => ({
|
||||||
|
abi: clPmAbi,
|
||||||
|
address: ADDR.CL_PM,
|
||||||
|
functionName: 'tokenOfOwnerByIndex',
|
||||||
|
args: [user, BigInt(i)],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const walletIds = idRes.map((r) => ok<bigint>(r)).filter((x): x is bigint => x !== undefined)
|
||||||
|
|
||||||
|
// pass 3: position structs (+ earned for staked)
|
||||||
|
const stakedFlat = stakedIdsByGauge.flatMap(({ pool, ids }) => ids.map((id) => ({ pool, id })))
|
||||||
|
const pass3: unknown[] = [
|
||||||
|
...walletIds.map((id) => ({
|
||||||
|
abi: clPmAbi,
|
||||||
|
address: ADDR.CL_PM,
|
||||||
|
functionName: 'positions',
|
||||||
|
args: [id],
|
||||||
|
})),
|
||||||
|
...stakedFlat.flatMap(({ pool, id }) => [
|
||||||
|
{ abi: clPmAbi, address: ADDR.CL_PM, functionName: 'positions', args: [id] },
|
||||||
|
{ abi: clGaugeAbi, address: pool.gauge!, functionName: 'earned', args: [user, id] },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
const r3 = await mc(pc, pass3)
|
||||||
|
|
||||||
|
const poolByKey = new Map<string, ClPool>()
|
||||||
|
for (const p of clPools) {
|
||||||
|
poolByKey.set(`${p.token0.toLowerCase()}|${p.token1.toLowerCase()}|${p.tickSpacing}`, p)
|
||||||
|
}
|
||||||
|
const findPool = (raw: RawPos): ClPool | undefined =>
|
||||||
|
poolByKey.get(`${raw[2].toLowerCase()}|${raw[3].toLowerCase()}|${raw[4]}`)
|
||||||
|
|
||||||
|
const cl: ClPosition[] = []
|
||||||
|
|
||||||
|
const buildPos = (
|
||||||
|
id: bigint,
|
||||||
|
raw: RawPos,
|
||||||
|
staked: boolean,
|
||||||
|
earned: bigint,
|
||||||
|
): ClPosition | null => {
|
||||||
|
const pool = findPool(raw)
|
||||||
|
if (!pool) return null
|
||||||
|
const liquidity = raw[7]
|
||||||
|
const { amount0, amount1 } = getAmountsForLiquidity(
|
||||||
|
pool.sqrtPriceX96,
|
||||||
|
getSqrtRatioAtTick(raw[5]),
|
||||||
|
getSqrtRatioAtTick(raw[6]),
|
||||||
|
liquidity,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
tokenId: id,
|
||||||
|
pool,
|
||||||
|
tickLower: raw[5],
|
||||||
|
tickUpper: raw[6],
|
||||||
|
liquidity,
|
||||||
|
staked,
|
||||||
|
amount0,
|
||||||
|
amount1,
|
||||||
|
fees0: raw[10],
|
||||||
|
fees1: raw[11],
|
||||||
|
earned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walletIds.forEach((id, j) => {
|
||||||
|
const raw = ok<RawPos>(r3[j])
|
||||||
|
if (!raw) return
|
||||||
|
const pos = buildPos(id, raw, false, 0n)
|
||||||
|
if (pos && (pos.liquidity > 0n || pos.fees0 > 0n || pos.fees1 > 0n)) cl.push(pos)
|
||||||
|
})
|
||||||
|
stakedFlat.forEach(({ id }, j) => {
|
||||||
|
const base = walletIds.length + j * 2
|
||||||
|
const raw = ok<RawPos>(r3[base])
|
||||||
|
const earned = ok<bigint>(r3[base + 1]) ?? 0n
|
||||||
|
if (!raw) return
|
||||||
|
const pos = buildPos(id, raw, true, earned)
|
||||||
|
if (pos) cl.push(pos)
|
||||||
|
})
|
||||||
|
|
||||||
|
// univ3 wallet positions join here so the fee simulation below covers them
|
||||||
|
const uni = await uniP
|
||||||
|
cl.push(...uni.cl)
|
||||||
|
|
||||||
|
// pass 4: exact uncollected fees for wallet positions via collect() simulation
|
||||||
|
// (collect is signature-identical on both NPMs — only the address differs)
|
||||||
|
await Promise.all(
|
||||||
|
cl
|
||||||
|
.filter((p) => !p.staked)
|
||||||
|
.map(async (p) => {
|
||||||
|
try {
|
||||||
|
const sim = await pc.simulateContract({
|
||||||
|
abi: clPmAbi,
|
||||||
|
address: p.pool.protocol === 'univ3' ? UNI.V3_NPM : ADDR.CL_PM,
|
||||||
|
functionName: 'collect',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
tokenId: p.tokenId,
|
||||||
|
recipient: user,
|
||||||
|
amount0Max: MAX_UINT128,
|
||||||
|
amount1Max: MAX_UINT128,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
account: user,
|
||||||
|
})
|
||||||
|
const [f0, f1] = sim.result as readonly [bigint, bigint]
|
||||||
|
p.fees0 = f0
|
||||||
|
p.fees1 = f1
|
||||||
|
} catch {
|
||||||
|
/* keep tokensOwed fallback */
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const v2: V2Position[] = v2Raw.map((r) => {
|
||||||
|
const lp = r.walletLp + r.stakedLp
|
||||||
|
const ts = r.pool.totalSupply
|
||||||
|
return {
|
||||||
|
pool: r.pool,
|
||||||
|
walletLp: r.walletLp,
|
||||||
|
stakedLp: r.stakedLp,
|
||||||
|
earned: r.earned,
|
||||||
|
claimable0: r.claimable0,
|
||||||
|
claimable1: r.claimable1,
|
||||||
|
amount0: ts > 0n ? (lp * r.pool.reserve0) / ts : 0n,
|
||||||
|
amount1: ts > 0n ? (lp * r.pool.reserve1) / ts : 0n,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// staked first, then wallet up33, then univ3
|
||||||
|
const rank = (p: ClPosition) => (p.staked ? 0 : p.pool.protocol === 'up33' ? 1 : 2)
|
||||||
|
cl.sort((a, b) => rank(a) - rank(b))
|
||||||
|
return { cl, v2, tokens: uni.tokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePositions(user?: Address) {
|
||||||
|
const pc = usePublicClient()
|
||||||
|
const pools = usePools()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['positions', user],
|
||||||
|
enabled: !!pc && !!user && !!pools.data,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
queryFn: () => fetchPositions(pc as PublicClient, user!, pools.data!),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { usePublicClient } from 'wagmi'
|
||||||
|
import type { Address, PublicClient } from 'viem'
|
||||||
|
import { quoterAbi, v2RouterAbi } from '../abi'
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import { NATIVE, kyberRoute } from '../lib/kyber'
|
||||||
|
import type { ClPool, PoolsData, V2Pool } from '../types'
|
||||||
|
import { usePools } from './usePools'
|
||||||
|
|
||||||
|
export const isNative = (a?: Address) => !!a && a.toLowerCase() === NATIVE.toLowerCase()
|
||||||
|
export const erc20Of = (a: Address): Address => (isNative(a) ? ADDR.WETH : a)
|
||||||
|
|
||||||
|
export type NativeCandidate =
|
||||||
|
| { kind: 'v2'; pool: V2Pool; amountOut: bigint }
|
||||||
|
| { kind: 'cl'; pool: ClPool; amountOut: bigint }
|
||||||
|
|
||||||
|
export type NativeQuote = {
|
||||||
|
best: NativeCandidate | null
|
||||||
|
candidates: NativeCandidate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNativeQuote(
|
||||||
|
pc: PublicClient,
|
||||||
|
pools: PoolsData,
|
||||||
|
tokenIn: Address,
|
||||||
|
tokenOut: Address,
|
||||||
|
amountIn: bigint,
|
||||||
|
): Promise<NativeQuote> {
|
||||||
|
const tIn = erc20Of(tokenIn).toLowerCase()
|
||||||
|
const tOut = erc20Of(tokenOut).toLowerCase()
|
||||||
|
const matches = pools.pools.filter((p) => {
|
||||||
|
const a = p.token0.toLowerCase()
|
||||||
|
const b = p.token1.toLowerCase()
|
||||||
|
return (a === tIn && b === tOut) || (a === tOut && b === tIn)
|
||||||
|
})
|
||||||
|
if (!matches.length || tIn === tOut) return { best: null, candidates: [] }
|
||||||
|
|
||||||
|
const calls = matches.map((p) =>
|
||||||
|
p.kind === 'v2'
|
||||||
|
? {
|
||||||
|
abi: v2RouterAbi,
|
||||||
|
address: ADDR.V2_ROUTER,
|
||||||
|
functionName: 'getAmountsOut',
|
||||||
|
args: [
|
||||||
|
amountIn,
|
||||||
|
[{ from: erc20Of(tokenIn), to: erc20Of(tokenOut), stable: p.stable, factory: ADDR.V2_FACTORY }],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
abi: quoterAbi,
|
||||||
|
address: ADDR.CL_QUOTER,
|
||||||
|
functionName: 'quoteExactInputSingle',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
tokenIn: erc20Of(tokenIn),
|
||||||
|
tokenOut: erc20Of(tokenOut),
|
||||||
|
amountIn,
|
||||||
|
tickSpacing: p.tickSpacing,
|
||||||
|
sqrtPriceLimitX96: 0n,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const res = (await pc.multicall({ contracts: calls as never })) as {
|
||||||
|
status: 'success' | 'failure'
|
||||||
|
result?: unknown
|
||||||
|
}[]
|
||||||
|
|
||||||
|
const candidates: NativeCandidate[] = []
|
||||||
|
res.forEach((r, i) => {
|
||||||
|
if (r.status !== 'success') return
|
||||||
|
const p = matches[i]
|
||||||
|
if (p.kind === 'v2') {
|
||||||
|
const amounts = r.result as readonly bigint[]
|
||||||
|
const out = amounts[amounts.length - 1]
|
||||||
|
if (out > 0n) candidates.push({ kind: 'v2', pool: p, amountOut: out })
|
||||||
|
} else {
|
||||||
|
const [out] = r.result as readonly [bigint, bigint, number, bigint]
|
||||||
|
if (out > 0n) candidates.push({ kind: 'cl', pool: p, amountOut: out })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
candidates.sort((a, b) => (b.amountOut > a.amountOut ? 1 : b.amountOut < a.amountOut ? -1 : 0))
|
||||||
|
return { best: candidates[0] ?? null, candidates }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNativeQuote(tokenIn?: Address, tokenOut?: Address, amountIn?: bigint) {
|
||||||
|
const pc = usePublicClient()
|
||||||
|
const pools = usePools()
|
||||||
|
const enabled =
|
||||||
|
!!pc && !!pools.data && !!tokenIn && !!tokenOut && !!amountIn && amountIn > 0n &&
|
||||||
|
erc20Of(tokenIn).toLowerCase() !== erc20Of(tokenOut).toLowerCase()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['nativeQuote', tokenIn, tokenOut, amountIn?.toString()],
|
||||||
|
enabled,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
queryFn: () =>
|
||||||
|
fetchNativeQuote(pc as PublicClient, pools.data!, tokenIn!, tokenOut!, amountIn!),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKyberQuote(tokenIn?: Address, tokenOut?: Address, amountIn?: bigint) {
|
||||||
|
const enabled =
|
||||||
|
!!tokenIn && !!tokenOut && !!amountIn && amountIn > 0n &&
|
||||||
|
tokenIn.toLowerCase() !== tokenOut.toLowerCase() &&
|
||||||
|
// wrap/unwrap is not a swap
|
||||||
|
!(
|
||||||
|
(isNative(tokenIn) && tokenOut.toLowerCase() === ADDR.WETH.toLowerCase()) ||
|
||||||
|
(isNative(tokenOut) && tokenIn.toLowerCase() === ADDR.WETH.toLowerCase())
|
||||||
|
)
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['kyberQuote', tokenIn, tokenOut, amountIn?.toString()],
|
||||||
|
enabled,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
retry: 1,
|
||||||
|
queryFn: ({ signal }) => kyberRoute(tokenIn!, tokenOut!, amountIn!, { signal }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { Address } from 'viem'
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import { NATIVE, kyberTokenList } from '../lib/kyber'
|
||||||
|
import type { TokenInfo } from '../types'
|
||||||
|
import { usePools } from './usePools'
|
||||||
|
|
||||||
|
const PINNED: string[] = [NATIVE, ADDR.WETH, ADDR.UP, ADDR.USDG].map((a) => a.toLowerCase())
|
||||||
|
|
||||||
|
/** merged token list for the swap picker: ETH + pool tokens + ks-setting registry */
|
||||||
|
export function useTokenList(): TokenInfo[] {
|
||||||
|
const pools = usePools()
|
||||||
|
const kyber = useQuery({
|
||||||
|
queryKey: ['kyberTokens'],
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
refetchInterval: false,
|
||||||
|
queryFn: kyberTokenList,
|
||||||
|
})
|
||||||
|
|
||||||
|
const map = new Map<string, TokenInfo>()
|
||||||
|
map.set(NATIVE.toLowerCase(), {
|
||||||
|
address: NATIVE as Address,
|
||||||
|
symbol: 'ETH',
|
||||||
|
decimals: 18,
|
||||||
|
native: true,
|
||||||
|
})
|
||||||
|
if (pools.data) {
|
||||||
|
for (const [k, t] of Object.entries(pools.data.tokens)) map.set(k, t)
|
||||||
|
}
|
||||||
|
for (const t of kyber.data ?? []) {
|
||||||
|
const k = t.address.toLowerCase()
|
||||||
|
if (k === NATIVE.toLowerCase()) continue
|
||||||
|
if (!map.has(k)) map.set(k, { address: t.address, symbol: t.symbol, decimals: t.decimals })
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = [...map.values()]
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const ai = PINNED.indexOf(a.address.toLowerCase())
|
||||||
|
const bi = PINNED.indexOf(b.address.toLowerCase())
|
||||||
|
if (ai !== -1 || bi !== -1) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
|
||||||
|
return a.symbol.localeCompare(b.symbol)
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// TVL/volume stats for SPECIFIC uniswap pools (the ones the user holds
|
||||||
|
// positions in), straight from the pool indexer's address search. Kept apart
|
||||||
|
// from useUniPools (catalog browse) so POSITIONS doesn't drag a 120-row page.
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { Address } from 'viem'
|
||||||
|
import { fetchUniIndex } from '../lib/uniIndex'
|
||||||
|
import type { PoolStat } from '../lib/poolstats'
|
||||||
|
|
||||||
|
export function useUniPoolStats(addrs: Address[]) {
|
||||||
|
const key = addrs
|
||||||
|
.map((a) => a.toLowerCase())
|
||||||
|
.sort()
|
||||||
|
.join(',')
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['uniPoolStats', key],
|
||||||
|
enabled: addrs.length > 0,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 50_000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const out: Record<string, PoolStat> = {}
|
||||||
|
await Promise.all(
|
||||||
|
addrs.map(async (a) => {
|
||||||
|
const r = await fetchUniIndex(a, 0, undefined, 4).catch(() => null)
|
||||||
|
if (r) Object.assign(out, r.stats)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { usePublicClient } from 'wagmi'
|
||||||
|
import type { PublicClient } from 'viem'
|
||||||
|
import { fetchUniBrowse } from '../lib/uniBrowse'
|
||||||
|
import { fetchUniIndex } from '../lib/uniIndex'
|
||||||
|
import type { PoolStat } from '../lib/poolstats'
|
||||||
|
import type { Pool, TokenInfo } from '../types'
|
||||||
|
|
||||||
|
export type UniPoolsData = {
|
||||||
|
pools: Pool[]
|
||||||
|
tokens: Record<string, TokenInfo>
|
||||||
|
stats: Record<string, PoolStat>
|
||||||
|
total: number // matches before the page limit (index) / pre-cap candidates (fallback)
|
||||||
|
indexed: number // full catalog size; 0 in fallback mode
|
||||||
|
dropped: number // fallback only: spoof candidates dropped by factory.getPool
|
||||||
|
source: 'index' | 'fallback'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniswap pool browser — `query`: token address / pool address / symbol /
|
||||||
|
* "sym0/sym1"; '' = whole catalog by TVL. Primary source is the pool-indexer
|
||||||
|
* API (full v2+v3 catalog, factory-event-authentic). When the indexer is down
|
||||||
|
* or warming up it falls back to client-side dexscreener discovery with
|
||||||
|
* on-chain factory.getPool verification (v3 only, top 30).
|
||||||
|
*/
|
||||||
|
export function useUniPools(query: string, minTvl: number, proto?: 'univ2' | 'univ3') {
|
||||||
|
const pc = usePublicClient()
|
||||||
|
return useQuery<UniPoolsData>({
|
||||||
|
queryKey: ['uniPools', query.trim().toLowerCase(), minTvl, proto ?? 'all'],
|
||||||
|
enabled: !!pc,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const idx = await fetchUniIndex(query, minTvl, proto)
|
||||||
|
if (idx) return { ...idx, dropped: 0, source: 'index' }
|
||||||
|
const legacy = await fetchUniBrowse(pc as PublicClient, query)
|
||||||
|
return {
|
||||||
|
pools: legacy.pools,
|
||||||
|
tokens: legacy.tokens,
|
||||||
|
stats: legacy.stats,
|
||||||
|
total: legacy.candidates,
|
||||||
|
indexed: 0,
|
||||||
|
dropped: legacy.dropped,
|
||||||
|
source: 'fallback',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { parseUnits } from 'viem'
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import { kyberRoute } from '../lib/kyber'
|
||||||
|
|
||||||
|
/** USD price of 1 UP via a kyber UP->USDG quote (display only) */
|
||||||
|
export function useUpPrice() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['upPrice'],
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
staleTime: 50_000,
|
||||||
|
retry: 1,
|
||||||
|
queryFn: async () => {
|
||||||
|
// applyFee: false — this is a price display, not an executable quote
|
||||||
|
const r = await kyberRoute(ADDR.UP, ADDR.USDG, parseUnits('1', 18), { applyFee: false })
|
||||||
|
const usd = Number(r.routeSummary.amountOutUsd ?? NaN)
|
||||||
|
if (Number.isFinite(usd) && usd > 0) return usd
|
||||||
|
return Number(r.routeSummary.amountOut) / 1e6 // USDG has 6 decimals
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+476
@@ -0,0 +1,476 @@
|
|||||||
|
// English catalog — the source of truth for keys. zh.ts must mirror this
|
||||||
|
// shape exactly (enforced by its `typeof en` annotation). Keys are grouped by
|
||||||
|
// surface; interpolation uses i18next {{var}} syntax.
|
||||||
|
export const en = {
|
||||||
|
common: {
|
||||||
|
connectWallet: 'CONNECT WALLET',
|
||||||
|
insufficientBalance: 'INSUFFICIENT BALANCE',
|
||||||
|
insufficient: 'insufficient',
|
||||||
|
exceedsBalance: 'exceeds balance',
|
||||||
|
bal: 'bal',
|
||||||
|
max: 'MAX',
|
||||||
|
close: '× CLOSE',
|
||||||
|
noMatch: 'no match',
|
||||||
|
gasToken: '(gas)',
|
||||||
|
tokenSearch: 'symbol or address…',
|
||||||
|
},
|
||||||
|
hdr: {
|
||||||
|
pools: 'POOLS',
|
||||||
|
positions: 'POSITIONS',
|
||||||
|
swap: 'SWAP',
|
||||||
|
epoch: 'epoch',
|
||||||
|
flip: 'flip',
|
||||||
|
blk: 'blk',
|
||||||
|
connect: '[ CONNECT ]',
|
||||||
|
wrongChain: '[ WRONG CHAIN ]',
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
wrongNetwork: '!! WRONG NETWORK — this terminal only writes on Robinhood Chain (4663)',
|
||||||
|
switch: 'SWITCH',
|
||||||
|
tagline: 'LP TERMINAL v0.2 · exact approvals · live on-chain reads',
|
||||||
|
keys: 'keys: [1] pools [2] positions [3] swap [4] limit',
|
||||||
|
blockscout: 'blockscout↗',
|
||||||
|
reloaded: 'reloaded — a new build was deployed while this tab was open',
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
ready: '> ready. connect wallet to begin.',
|
||||||
|
events: '{{n}} events this session',
|
||||||
|
clear: 'CLEAR',
|
||||||
|
},
|
||||||
|
rpc: {
|
||||||
|
toggleTip: 'chain-read RPC endpoint — click to set your own (stored in this browser only)',
|
||||||
|
placeholder: 'https://… JSON-RPC url (chain 4663) · empty = default',
|
||||||
|
apply: 'APPLY',
|
||||||
|
checking: 'CHECKING…',
|
||||||
|
reset: 'RESET',
|
||||||
|
notHttp: 'not an http(s) url',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
label: 'theme:',
|
||||||
|
switchTip: 'switch to the {{name}} theme',
|
||||||
|
},
|
||||||
|
lang: { label: 'lang:' },
|
||||||
|
rbar: {
|
||||||
|
inRange: 'IN RANGE · {{pct}}% through · band ±{{band}}%',
|
||||||
|
outRise: 'OUT OF RANGE · price must rise {{pct}}% to re-enter',
|
||||||
|
outFall: 'OUT OF RANGE · price must fall {{pct}}% to re-enter',
|
||||||
|
orderFilled: 'ORDER FULLY FILLED · holding 100% {{sym}} — withdraw to lock in',
|
||||||
|
orderFilling: 'ORDER FILLING · {{pct}}% of {{sym}} sold · un-fills if price retreats',
|
||||||
|
orderWaitRise: 'ORDER WAITING · price must rise {{pct}}% to start filling',
|
||||||
|
orderWaitFall: 'ORDER WAITING · price must fall {{pct}}% to start filling',
|
||||||
|
toLow: '→ LOW',
|
||||||
|
fromHigh: 'HIGH ←',
|
||||||
|
flipTip: 'flip price orientation',
|
||||||
|
ticksTip: 'ticks [{{lo}}, {{hi}}] · current {{tick}}',
|
||||||
|
},
|
||||||
|
tx: {
|
||||||
|
confirm: '{{label}} — confirm in wallet…',
|
||||||
|
pending: '{{label}} — pending',
|
||||||
|
reverted: '{{label}} — REVERTED',
|
||||||
|
ok: '{{label}} — ok @ block {{n}}',
|
||||||
|
approve: 'approve {{sym}}',
|
||||||
|
received: 'received {{amt}} UP',
|
||||||
|
swapToEth: 'SWAP → ETH',
|
||||||
|
hintKyberMinOut: 'kyber min-out hit — price moved between quote & send; raise SLIP or retry',
|
||||||
|
hintPS: 'price slippage — pool price moved beyond tolerance, just retry',
|
||||||
|
hintV2Ratio: 'v2 pool ratio moved after amounts were linked — re-enter the amount & retry',
|
||||||
|
hintSTF: 'token transfer failed — check allowance / balance',
|
||||||
|
hintNP: 'not approved for this token id',
|
||||||
|
hintDeadline: 'deadline passed — retry',
|
||||||
|
},
|
||||||
|
kyber: {
|
||||||
|
routerMismatch: 'router mismatch {{addr}} — ABORTED',
|
||||||
|
badValue: 'unexpected tx value {{got}} (want {{want}}) — ABORTED',
|
||||||
|
buildDeviates: 'built route deviates from quote (in {{in}}, out {{out}}) — ABORTED',
|
||||||
|
},
|
||||||
|
pools: {
|
||||||
|
searchPlaceholder: 'search pair / symbol / token 0x… / pool 0x… ( / )',
|
||||||
|
protoAll: 'ALL',
|
||||||
|
protoAllTip: 'every protocol',
|
||||||
|
protoUp33Tip: 'up33 ve(3,3) pools only',
|
||||||
|
protoV3Tip: 'uniswap v3 pools only',
|
||||||
|
protoV2Tip: 'uniswap v2 pairs only',
|
||||||
|
hideDust: 'HIDE <$1K',
|
||||||
|
hideDustTip: 'hide pools under $1k TVL — 95% of the uniswap catalog is dust meme pools',
|
||||||
|
mine: '● MINE ({{n}})',
|
||||||
|
loading: '> enumerating pools from factories',
|
||||||
|
scanFailed: '> pool scan failed: {{err}}',
|
||||||
|
statShown: '{{n}} shown',
|
||||||
|
statUp33: 'up33 {{n}}',
|
||||||
|
statUniswap: 'uniswap',
|
||||||
|
statCatalog: 'catalog {{n}}',
|
||||||
|
statMatch: '{{n}} match',
|
||||||
|
statTop: 'top {{n}} by tvl',
|
||||||
|
statVol: 'vol',
|
||||||
|
uniScanFailed: 'uniswap scan failed: {{err}}',
|
||||||
|
fallbackNote: 'indexer offline — dexscreener fallback (v3 top 30)',
|
||||||
|
spoofDropped: '{{n}} spoof dropped',
|
||||||
|
thPair: 'PAIR',
|
||||||
|
thPrice: 'PRICE / RESERVES',
|
||||||
|
thTvl: 'TVL',
|
||||||
|
thVol: 'VOL 24H',
|
||||||
|
thFees: 'FEES 24H',
|
||||||
|
thFeeApr: 'FEE APR',
|
||||||
|
thRewards: 'REWARDS',
|
||||||
|
sortTip: 'click to sort',
|
||||||
|
mineDotTip: 'you have a position here',
|
||||||
|
gauge: 'gauge',
|
||||||
|
killed: 'killed',
|
||||||
|
noGauge: 'no gauge',
|
||||||
|
feeTipUniV2: 'no levy — 0.30% fees accrue into reserves (auto-compound for LPs)',
|
||||||
|
feeTipUniV3: 'no ve(3,3) levy — LPs keep 100% of trade fees',
|
||||||
|
rewardsTip:
|
||||||
|
'up33 only: staked-LP UP yield (post-cap live rate) · weekly emissions · gauge vote share — uniswap pools have no emissions, LPs keep the fees instead. Full detail under the UP33 filter.',
|
||||||
|
upWk: '{{n}} UP/wk',
|
||||||
|
noEmissions: 'no emissions',
|
||||||
|
vote: 'vote {{n}}%',
|
||||||
|
stakedPct: '{{n}}% staked',
|
||||||
|
addLp: '+ LP',
|
||||||
|
footnote:
|
||||||
|
'<0>FEE APR</0> = unstaked LP fee yield · <1>REWARDS</1> = up33 staked UP emissions APR · a position earns fees OR emissions, never both',
|
||||||
|
footnoteTip:
|
||||||
|
'sources: pool indexer (chain-derived TVL + geckoterminal vol) for uniswap · dexscreener + goldsky subgraph for up33 · fees 24h = vol × fee rate (gross) · simple APR, no compounding',
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
fund: 'FUND',
|
||||||
|
fundPair: 'PAIR',
|
||||||
|
fundPairTip: "supply both tokens in the pool's ratio",
|
||||||
|
fundZap: '⚡ ZAP — ONE TOKEN',
|
||||||
|
fundZapTip: 'hold only one side? zap swaps the right slice into the other token, then deposits — step by step',
|
||||||
|
range: 'RANGE',
|
||||||
|
full: 'FULL',
|
||||||
|
pctCustom: '± % …',
|
||||||
|
pctCustomTip: 'symmetric band, custom percent',
|
||||||
|
above: '↑ ABOVE',
|
||||||
|
aboveTip: 'one-sided above current price — deposits {{sym}} only, starts earning as price rises into it (sell-the-rise)',
|
||||||
|
below: '↓ BELOW',
|
||||||
|
belowTip: 'one-sided below current price — deposits {{sym}} only, starts earning as price falls into it (buy-the-dip)',
|
||||||
|
price: 'PRICE',
|
||||||
|
priceTip: 'enter exact price bounds',
|
||||||
|
ticks: 'TICKS',
|
||||||
|
ticksTip: 'raw tick bounds',
|
||||||
|
priceLo: 'lower price',
|
||||||
|
priceHi: 'upper price',
|
||||||
|
priceUnits: '{{quote}}/{{base}} · snaps to ts{{ts}}',
|
||||||
|
tickLower: 'tickLower',
|
||||||
|
tickUpper: 'tickUpper',
|
||||||
|
spacing: 'spacing {{ts}}',
|
||||||
|
limitHint: 'selling a fixed amount?',
|
||||||
|
limitHintLink: 'SWAP → LIMIT',
|
||||||
|
limitHintRest: 'places it as a tracked range order',
|
||||||
|
aboveNote: 'price above range — token1 only',
|
||||||
|
belowNote: 'price below range — token0 only',
|
||||||
|
projected: 'PROJECTED',
|
||||||
|
projOut: 'range does not cover current price — earns NOTHING until price enters it',
|
||||||
|
projDep: 'dep ≈ {{usd}}',
|
||||||
|
projFeeApr: 'fee APR',
|
||||||
|
projIfUnstaked: '(if unstaked)',
|
||||||
|
projEmitApr: 'emit APR',
|
||||||
|
projIfStaked: '(if staked)',
|
||||||
|
projShare: '{{pct}}% of active liq · while in range',
|
||||||
|
mint: 'MINT POSITION',
|
||||||
|
mintHintUni: 'NFT lands in wallet — manage it from POSITIONS (uniswap: no staking, fees stay yours)',
|
||||||
|
mintHintUp33: 'NFT lands in wallet — stake it from POSITIONS to earn UP',
|
||||||
|
addLiquidity: 'ADD LIQUIDITY',
|
||||||
|
v2Hint: 'amounts auto-balanced to reserves · mins {{slip}}%',
|
||||||
|
v2HintUni: 'LP tokens land in wallet (uniswap v2: no staking, fees auto-compound in reserves)',
|
||||||
|
v2HintUp33: 'LP token lands in wallet, stake it from POSITIONS',
|
||||||
|
setRangeFirst: '> set a valid range first — the zap split depends on it.',
|
||||||
|
stepAddV2: 'add v2 {{pair}} liquidity',
|
||||||
|
stepAdd: 'add {{pair}} liquidity',
|
||||||
|
stepMint: 'mint {{kind}} {{pair}} [{{lo}},{{hi}}]',
|
||||||
|
},
|
||||||
|
zap: {
|
||||||
|
zapIn: 'ZAP IN',
|
||||||
|
ethTip: 'native ETH — wrapped to WETH as step 1',
|
||||||
|
slip: 'SLIP',
|
||||||
|
slipHint: 'swap leg min-out · deposit mins 1% band-edge',
|
||||||
|
solving: '> solving split via kyber',
|
||||||
|
cantPlan: "> can't plan: {{err}}",
|
||||||
|
planTitle: 'ZAP PLAN',
|
||||||
|
split: 'SPLIT',
|
||||||
|
keepAll: 'keep all {{amt}} {{sym}}',
|
||||||
|
keepSwap: 'keep {{keep}} + swap {{swap}} {{sym}}',
|
||||||
|
splitSdSingle: 'range takes this token only at the current price — no swap needed',
|
||||||
|
splitSd: 'sized so both piles match the deposit ratio at the current price',
|
||||||
|
swapRow: 'SWAP',
|
||||||
|
swapMin: 'min {{amt}} @ {{slip}}%',
|
||||||
|
impact: 'impact {{pct}}%',
|
||||||
|
impactOff: 'impact n/a (usd marks unreliable — trust min-out)',
|
||||||
|
via: 'via {{route}}',
|
||||||
|
depositRow: 'DEPOSIT',
|
||||||
|
dustSd: 'est. dust {{dust}} stays in wallet (never sent)',
|
||||||
|
actualSd: 'swapped side deposits what ACTUALLY arrives, not the quote',
|
||||||
|
bandWarn: 'swap impact {{impact}}% is large vs the ±{{band}}% band — expect dust; widen the range or zap less',
|
||||||
|
run: '⚡ RUN ZAP',
|
||||||
|
runTx: '⚡ RUN ZAP ({{n}} tx)',
|
||||||
|
runHint: 'halts on any failure — funds stay in your wallet, nothing strands',
|
||||||
|
halted:
|
||||||
|
'halted at step {{n}} — completed steps left everything as normal wallet balances. fix the issue and re-run (it re-plans from your CURRENT balances), or finish via PAIR mode.',
|
||||||
|
done: '✓ zap complete — liquidity is live.',
|
||||||
|
replanFailed: 'zap re-plan failed: {{err}}',
|
||||||
|
orderGrows: 'this grows the open range order — same band, same avg fill, larger size.',
|
||||||
|
// planner/executor (lib/zap.ts)
|
||||||
|
errAmount: 'enter an amount',
|
||||||
|
errNotInPool: 'token not in this pool',
|
||||||
|
errNoReserves: 'pool has no reserves — seed it with PAIR mode',
|
||||||
|
errNoPrice: 'pool price unavailable',
|
||||||
|
errZeroQuote: 'kyber quoted zero out — pool/route too thin',
|
||||||
|
errNoRoute: 'no kyber route for the counter-token',
|
||||||
|
errTooSmall: 'amounts too small for this range',
|
||||||
|
stWrap: 'wrap {{amt}} ETH → WETH',
|
||||||
|
stApproveKyber: 'approve {{sym}} → kyber router',
|
||||||
|
stApproveSpender: 'approve {{sym}} → {{spender}}',
|
||||||
|
spenderRouter: 'router',
|
||||||
|
spenderNpm: 'position manager',
|
||||||
|
stSwap: 'swap {{amt}} {{sym}} → ≈{{out}} {{outSym}}',
|
||||||
|
stIncrease: 'increase #{{id}}',
|
||||||
|
stMint: 'mint position',
|
||||||
|
stAddLiquidity: 'add liquidity',
|
||||||
|
halt: 'zap halted — {{msg}}',
|
||||||
|
haltRequote: 're-quote failed: {{err}}',
|
||||||
|
haltPriceMoved: 'price moved since preview (now {{now}}, previewed {{prev}}) — re-check and run again',
|
||||||
|
haltTokenOut: 'route tokenOut {{addr}} is not the pool counter-token — ABORTED',
|
||||||
|
haltNoTransfer: 'swap succeeded but no counter-token transfer found — investigate before retrying',
|
||||||
|
haltNothing: 'nothing to deposit',
|
||||||
|
haltDepositSmall: 'deposit too small for this range at the current price (swapped tokens are in your wallet)',
|
||||||
|
stakeHint: 'zap complete — stake the position to earn UP',
|
||||||
|
stakeHintAction: 'POSITIONS →',
|
||||||
|
},
|
||||||
|
pos: {
|
||||||
|
connectPrompt: '> connect wallet to enumerate positions. nothing here yet?',
|
||||||
|
browsePools: 'browse pools →',
|
||||||
|
scanning: '> scanning positions',
|
||||||
|
scanFailed: '> position scan failed: {{err}}',
|
||||||
|
empty: '> no LP positions found for {{addr}}…',
|
||||||
|
emptyCta: 'browse pools to add liquidity →',
|
||||||
|
lpValue: 'LP VALUE',
|
||||||
|
lpValueSub: '{{cl}} CL · {{v2}} v2 · {{staked}} staked',
|
||||||
|
lpValueFees: 'fees ≈{{usd}} uncollected',
|
||||||
|
lpValueUnpriced: '{{n}} unpriced',
|
||||||
|
pendingUp: 'PENDING UP',
|
||||||
|
upPerDay: '+{{n}} UP/day',
|
||||||
|
upPerDayUsd: '(≈{{usd}}/day)',
|
||||||
|
claimAll: 'CLAIM ALL ({{n}} tx)',
|
||||||
|
nothingClaimable: 'nothing claimable',
|
||||||
|
rangeStatus: 'RANGE STATUS',
|
||||||
|
outOfRangeN: '{{n}} OUT OF RANGE',
|
||||||
|
allInRange: 'ALL IN RANGE',
|
||||||
|
rangeStatusSub: 'out-of-range CL earns nothing',
|
||||||
|
ordersNotCounted: 'range orders not counted',
|
||||||
|
rangeOrders: 'RANGE ORDERS',
|
||||||
|
ordersOpen: '{{n}} open',
|
||||||
|
ordersFilled: '{{n}} FULLY FILLED — withdraw to lock in ↓',
|
||||||
|
ordersNone: 'none filled yet — they fill as the sell token appreciates',
|
||||||
|
sectionCl: 'CL POSITIONS ({{n}})',
|
||||||
|
sectionV2: 'V2 POSITIONS ({{n}})',
|
||||||
|
noCl: 'no CL positions.',
|
||||||
|
noClCta: 'open one from POOLS →',
|
||||||
|
noV2: 'no v2 LP.',
|
||||||
|
noV2Cta: 'add from POOLS →',
|
||||||
|
staked: 'STAKED',
|
||||||
|
wallet: 'WALLET',
|
||||||
|
limitBadge: 'LIMIT {{sell}}→{{buy}}',
|
||||||
|
claimUp: 'CLAIM UP',
|
||||||
|
claimUpTip: 'claim pending UP emissions',
|
||||||
|
unstake: 'UNSTAKE',
|
||||||
|
unstakeTip: 'unstake to modify liquidity or collect fees — withdrawal auto-claims pending UP',
|
||||||
|
stake: 'STAKE',
|
||||||
|
stakeTip: 'stake NFT into gauge to earn UP (fees go to voters while staked)',
|
||||||
|
collectFees: 'COLLECT FEES',
|
||||||
|
collectFeesTip: 'collect uncollected swap fees',
|
||||||
|
inc: '+ INC',
|
||||||
|
incTip: 'add liquidity into the same range — the band is fixed at mint and cannot move',
|
||||||
|
dec: '− DEC',
|
||||||
|
withdraw: 'WITHDRAW',
|
||||||
|
withdrawConfirm: 'CONFIRM?',
|
||||||
|
withdrawTip: 'remove 100% liquidity + collect everything (click twice)',
|
||||||
|
value: 'value',
|
||||||
|
noAnchor: '— no price anchor',
|
||||||
|
holds: 'holds',
|
||||||
|
pendingUpK: 'pending UP',
|
||||||
|
fees: 'unclaimed fees',
|
||||||
|
levyNote: '(10% unstaked levy already applied)',
|
||||||
|
earning: 'earning',
|
||||||
|
earnEmit: '≈ {{n}} UP/day',
|
||||||
|
earnUsdDay: '({{usd}}/day)',
|
||||||
|
earnApr: 'APR ≈ {{apr}}',
|
||||||
|
earnShareStaked: '{{share}} of staked liq',
|
||||||
|
earnShareGauge: '{{share}} of gauge',
|
||||||
|
earnOutStaked: '0 — out of range',
|
||||||
|
earnOutStakedTip: 'staked CL pays UP only while price is inside the band',
|
||||||
|
earnEnded: '0 — UP emissions not flowing this epoch',
|
||||||
|
earnFeeApr: 'fee APR ≈',
|
||||||
|
earnShareActive: '{{share}} of active liq',
|
||||||
|
earnSharePool: '{{share}} of pool',
|
||||||
|
earnWhileInRange: 'while in range',
|
||||||
|
earnUnknown: 'accruing fees — no 24h volume data to rate it',
|
||||||
|
earnOut: '0 — out of range',
|
||||||
|
earnEmpty: 'empty — only uncollected fees remain',
|
||||||
|
earningStaked: 'earning (staked)',
|
||||||
|
earningWallet: 'earning (wallet)',
|
||||||
|
orderRow: 'range order {{sell}} → {{buy}}:',
|
||||||
|
orderLockIn: 'WITHDRAW → LOCK IN {{sym}}',
|
||||||
|
orderClose: 'CLOSE NOW (PARTIAL FILL)',
|
||||||
|
orderCancel: 'CANCEL — GET {{sym}} BACK',
|
||||||
|
remove: 'REMOVE',
|
||||||
|
removeHint: 'decrease + collect (2 tx), mins {{slip}}%',
|
||||||
|
incRange: 'RANGE',
|
||||||
|
incRangeSd: 'fixed at mint — adding stacks liquidity on the same band, never moves it',
|
||||||
|
incSize: 'SIZE',
|
||||||
|
incSizeAny: 'ANY',
|
||||||
|
incSizeSdSingle: 'no fixed total — single-sided while price is outside the band',
|
||||||
|
incSizeSd: 'no fixed total — the token split is pinned by the live pool price (fields auto-link)',
|
||||||
|
incPulls: 'PULLS',
|
||||||
|
incPullsSd: 'taken at current price — any excess of the over-supplied side stays in wallet',
|
||||||
|
incNew: 'NEW SIZE',
|
||||||
|
incNewReseed: 're-seeding an empty position',
|
||||||
|
incNewGrow: 'liquidity +{{pct}}%',
|
||||||
|
incFees: 'FEES',
|
||||||
|
incFeesKept: 'KEPT',
|
||||||
|
incFeesSd: 'accrued fees are credited to the position on increase — collect any time',
|
||||||
|
incOrder: 'ORDER',
|
||||||
|
incOrderGrows: 'GROWS',
|
||||||
|
incOrderSd: 'this adds to the open range order — same band, same avg fill, larger size',
|
||||||
|
incBtn: 'INCREASE LIQUIDITY',
|
||||||
|
incMins: "mins {{slip}}% band-edge — reverts 'PSC' if price moves further before mining",
|
||||||
|
v2StakeAll: 'STAKE ALL',
|
||||||
|
v2UnstakeAll: 'UNSTAKE ALL',
|
||||||
|
v2ClaimFees: 'CLAIM FEES',
|
||||||
|
v2Total: 'total',
|
||||||
|
v2WalletLp: 'wallet LP',
|
||||||
|
v2StakedLp: 'staked LP',
|
||||||
|
v2Claimable: 'claimable fees',
|
||||||
|
v2RemoveOf: 'of wallet LP',
|
||||||
|
v2RemoveStakedNote: '— staked LP must be unstaked first',
|
||||||
|
// tx step labels
|
||||||
|
stClaim: 'claim UP #{{id}}',
|
||||||
|
stClaimPair: 'claim UP ({{pair}})',
|
||||||
|
stClaimV2: 'claim UP {{pair}}',
|
||||||
|
stApproveNft: 'approve NFT #{{id}} to gauge',
|
||||||
|
stStake: 'stake #{{id}}',
|
||||||
|
stUnstake: 'unstake #{{id}}',
|
||||||
|
stCollect: 'collect fees #{{id}}',
|
||||||
|
stDecrease: 'decrease #{{id}} by {{pct}}%',
|
||||||
|
stCollectAll: 'collect #{{id}} (principal + fees)',
|
||||||
|
stNothingRemove: 'nothing to remove',
|
||||||
|
stStakeLp: 'stake {{pair}} LP',
|
||||||
|
stUnstakeLp: 'unstake {{pair}} LP',
|
||||||
|
stClaimPoolFees: 'claim pool fees ({{pair}})',
|
||||||
|
stRemoveLp: 'remove {{pct}}% {{pair}} LP',
|
||||||
|
stNoWalletLp: 'no wallet LP — unstake first',
|
||||||
|
},
|
||||||
|
swap: {
|
||||||
|
mode: 'MODE',
|
||||||
|
market: 'MARKET',
|
||||||
|
limit: 'LIMIT · SELL VIA LP',
|
||||||
|
limitTip: 'sell a token via a one-sided LP (CL range order)',
|
||||||
|
loadingTokens: '> loading token registry…',
|
||||||
|
from: 'FROM',
|
||||||
|
to: 'TO',
|
||||||
|
flipTip: 'flip direction',
|
||||||
|
rate: 'rate: 1 {{a}} = {{n}} {{b}}',
|
||||||
|
rateTip: 'click to invert',
|
||||||
|
quotes: 'QUOTES',
|
||||||
|
enterAmount: 'enter an amount to quote both routes.',
|
||||||
|
kyberAgg: 'KYBER AGG',
|
||||||
|
up33Native: 'UP33 NATIVE',
|
||||||
|
unavailable: 'unavailable: {{err}}',
|
||||||
|
gas: 'gas {{usd}}',
|
||||||
|
best: 'BEST',
|
||||||
|
viaV2: 'via v2 {{kind}} {{fee}}%',
|
||||||
|
stable: 'stable',
|
||||||
|
volatile: 'volatile',
|
||||||
|
viaCl: 'via CL {{fee}}% ts{{ts}}',
|
||||||
|
bpsVsKyber: '{{bps}} bps vs kyber',
|
||||||
|
noNativePool: 'no direct UP33 pool for this pair',
|
||||||
|
needsWeth: 'needs WETH — wrap first',
|
||||||
|
slippage: 'SLIPPAGE',
|
||||||
|
refresh: '↻ REFRESH',
|
||||||
|
execVia: 'EXECUTE VIA {{route}} →',
|
||||||
|
noRoute: 'NO ROUTE',
|
||||||
|
minReceived: 'min received: {{amt}} {{sym}} @ {{slip}}%',
|
||||||
|
nativeWethNote: 'native route delivers WETH (not ETH)',
|
||||||
|
wrapBtn: 'WRAP {{amt}} ETH',
|
||||||
|
unwrapBtn: 'UNWRAP {{amt}} WETH',
|
||||||
|
wrapNote: '1:1, no fee — WETH9 {{addr}}',
|
||||||
|
stWrap: 'wrap {{amt}} ETH → WETH',
|
||||||
|
stUnwrap: 'unwrap {{amt}} WETH → ETH',
|
||||||
|
stSwapKyber: 'swap {{amt}} {{a}} → {{b}} [KYBER]',
|
||||||
|
stSwapV2: 'swap {{amt}} {{a}} → {{b}} [UP33 v2]',
|
||||||
|
stSwapCl: 'swap {{amt}} {{a}} → {{b}} [UP33 CL ts{{ts}}]',
|
||||||
|
errNoQuote: 'kyber quote unavailable',
|
||||||
|
errNeedsWeth: 'UP33 native route needs WETH — wrap ETH first (select ETH→WETH)',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
intro: '> maker, not taker: park a one-sided LP beyond market — pay no swap fee, earn fees while it fills',
|
||||||
|
loadingPools: '> loading pools',
|
||||||
|
noPools: '> no initialized CL pools found.',
|
||||||
|
sell: 'SELL',
|
||||||
|
for: 'FOR',
|
||||||
|
pool: 'POOL',
|
||||||
|
noPairs: 'no CL pool pairs {{sym}} — pick another token',
|
||||||
|
noPoolFor: 'no CL pool for {{pair}}.',
|
||||||
|
market: 'market: 1 {{sell}} =',
|
||||||
|
poolChip: 'pool CL ts{{ts}} {{fee}}%',
|
||||||
|
sellBand: 'SELL BAND',
|
||||||
|
explainTip: 'what is a band?',
|
||||||
|
tight: 'TIGHT · 1 TICK',
|
||||||
|
tightTip: 'narrowest band hugging market — one tick spacing; fills on the first uptick, max maker-fee capture',
|
||||||
|
custom: 'CUSTOM',
|
||||||
|
aboveMarket: '% above market',
|
||||||
|
exTitle: 'BAND = LADDERED EXIT',
|
||||||
|
exStart: 'start',
|
||||||
|
exStartSd: 'first {{sym}} sold — closer fills sooner, but un-fills easier on a dip',
|
||||||
|
exEnd: 'end',
|
||||||
|
exEndSd: 'last {{sym}} sold — wider band = smoother ladder, more time earning fees',
|
||||||
|
exAvg: 'avg fill',
|
||||||
|
exAvgSd: 'geometric mean of start × end — your blended exit premium',
|
||||||
|
exGrid: 'grid',
|
||||||
|
exGridSd:
|
||||||
|
'band edges only on {{ts}}-tick lines (≈{{pct}}% steps) — TIGHT is the closest legal band; smaller custom values snap onto it',
|
||||||
|
orderTitle: 'ORDER',
|
||||||
|
fillStarts: 'fill starts',
|
||||||
|
fullySold: 'fully sold',
|
||||||
|
avgFill: 'avg fill',
|
||||||
|
priceEq: '1 {{sell}} = {{px}} {{buy}}',
|
||||||
|
priceApprox: '1 {{sell}} ≈ {{px}} {{buy}} · √(start·end)',
|
||||||
|
band: 'band',
|
||||||
|
bandSd: 'tick grid ts{{ts}} · ≈{{pct}}% steps',
|
||||||
|
bandTight: 'narrowest legal band',
|
||||||
|
bandSnaps: '≡ TIGHT — sub-spacing input snaps to the same band',
|
||||||
|
projTitle: 'PROJECTED · FULL FILL',
|
||||||
|
projSell: 'sell',
|
||||||
|
projSellSd: '@ avg {{px}} {{buy}}',
|
||||||
|
projVsMarket: '+{{pct}}% vs market',
|
||||||
|
projReceive: 'receive',
|
||||||
|
projWhenFilled: 'when fully filled',
|
||||||
|
projFeeIncome: 'fee income',
|
||||||
|
projFeeIncomeSd: 'while price sits in band · {{share}}% of active liquidity at current volume',
|
||||||
|
projFeePct: 'of volume traded through your band while filling',
|
||||||
|
feesTitle: 'FEES · MAKER VS TAKER',
|
||||||
|
thisOrder: 'this order',
|
||||||
|
makerSd: 'maker — pays no swap fee, earns them while filling',
|
||||||
|
marketSwap: 'market swap',
|
||||||
|
takerSd: 'taker — pool fee paid to LPs',
|
||||||
|
takerLoss: 'taker — ≈ {{usd}} lost on this size',
|
||||||
|
mechTitle: 'MECHANICS',
|
||||||
|
mechFills: 'fills',
|
||||||
|
mechFillsSd: 'converts to {{buy}} as price climbs through the band',
|
||||||
|
mechUnfills: 'un-fills',
|
||||||
|
mechUnfillsSd: 'converts back if price retreats — not a resting order, nothing auto-executes',
|
||||||
|
mechAfter: 'after fill',
|
||||||
|
mechWithdraw: 'WITHDRAW',
|
||||||
|
mechAfterSd: '→ one-click lock-in · LIMIT badge, live fill %',
|
||||||
|
mechStaking: 'staking',
|
||||||
|
mechDont: "DON'T",
|
||||||
|
mechStakingSd: 'staked LPs forfeit swap fees — leave the order unstaked',
|
||||||
|
invalidBand: 'enter a valid band (from% < to%, both > 0).',
|
||||||
|
place: 'PLACE RANGE ORDER →',
|
||||||
|
placeHint: '2 tx max: approve (exact) + mint · no auto-execution',
|
||||||
|
bandEntered: 'price already entered your band — pick a band further from market',
|
||||||
|
stPlace: 'place range order: sell {{amt}} {{sell}} → {{buy}}',
|
||||||
|
orderLive: 'range order #{{id}} live — fills as {{sym}} appreciates',
|
||||||
|
track: 'TRACK IN POSITIONS',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// i18n runtime: en/zh, persisted per browser, ?lang= view-only override
|
||||||
|
// (screenshots/sharing — mirrors ?theme=). The i18next singleton `t` is safe
|
||||||
|
// to import from non-React modules (tx step labels, zap planner); components
|
||||||
|
// use useTranslation() so language switches re-render live.
|
||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import { en } from './en'
|
||||||
|
import { zh } from './zh'
|
||||||
|
|
||||||
|
export type Lang = 'en' | 'zh'
|
||||||
|
const KEY = 'up33.lang.v1'
|
||||||
|
|
||||||
|
declare module 'i18next' {
|
||||||
|
interface CustomTypeOptions {
|
||||||
|
defaultNS: 'translation'
|
||||||
|
resources: { translation: typeof en }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLang(): Lang {
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams(location.search).get('lang')
|
||||||
|
if (q === 'en' || q === 'zh') return q // view-only — not persisted
|
||||||
|
const s = localStorage.getItem(KEY)
|
||||||
|
if (s === 'en' || s === 'zh') return s
|
||||||
|
} catch {
|
||||||
|
/* storage blocked */
|
||||||
|
}
|
||||||
|
return navigator.language?.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: { en: { translation: en }, zh: { translation: zh } },
|
||||||
|
lng: detectLang(),
|
||||||
|
fallbackLng: 'en',
|
||||||
|
interpolation: { escapeValue: false }, // React escapes; keep symbols like → intact
|
||||||
|
returnEmptyString: false,
|
||||||
|
})
|
||||||
|
document.documentElement.lang = i18n.language === 'zh' ? 'zh-CN' : 'en'
|
||||||
|
|
||||||
|
export function setLang(l: Lang): void {
|
||||||
|
void i18n.changeLanguage(l)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, l)
|
||||||
|
} catch {
|
||||||
|
/* storage blocked — applies for this tab */
|
||||||
|
}
|
||||||
|
document.documentElement.lang = l === 'zh' ? 'zh-CN' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentLang(): Lang {
|
||||||
|
return i18n.language?.startsWith('zh') ? 'zh' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** singleton translate for non-React modules (txlog/step labels, planners) */
|
||||||
|
export const t = i18n.t.bind(i18n)
|
||||||
|
export default i18n
|
||||||
+473
@@ -0,0 +1,473 @@
|
|||||||
|
// 中文语言包 — 结构与 en.ts 完全一致(typeof en 强制,缺键/多键编译报错)。
|
||||||
|
// 风格:terminal 简洁短语;DeFi 通用英文术语保留(TVL/APR/LP/CL/gauge/tick)。
|
||||||
|
import type { en } from './en'
|
||||||
|
|
||||||
|
export const zh: typeof en = {
|
||||||
|
common: {
|
||||||
|
connectWallet: '连接钱包',
|
||||||
|
insufficientBalance: '余额不足',
|
||||||
|
insufficient: '余额不足',
|
||||||
|
exceedsBalance: '超出余额',
|
||||||
|
bal: '余额',
|
||||||
|
max: 'MAX',
|
||||||
|
close: '× 收起',
|
||||||
|
noMatch: '无匹配',
|
||||||
|
gasToken: '(gas)',
|
||||||
|
tokenSearch: '符号或地址…',
|
||||||
|
},
|
||||||
|
hdr: {
|
||||||
|
pools: '池子',
|
||||||
|
positions: '仓位',
|
||||||
|
swap: '兑换',
|
||||||
|
epoch: 'epoch',
|
||||||
|
flip: '翻转',
|
||||||
|
blk: '块',
|
||||||
|
connect: '[ 连接 ]',
|
||||||
|
wrongChain: '[ 链错误 ]',
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
wrongNetwork: '!! 网络错误 — 本终端只在 Robinhood Chain (4663) 上写入',
|
||||||
|
switch: '切换',
|
||||||
|
tagline: 'LP TERMINAL v0.2 · 精确授权 · 链上实时读取',
|
||||||
|
keys: '快捷键: [1] 池子 [2] 仓位 [3] 兑换 [4] 限价',
|
||||||
|
blockscout: 'blockscout↗',
|
||||||
|
reloaded: '已刷新 — 此标签页打开期间部署了新版本',
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
ready: '> 就绪。连接钱包开始。',
|
||||||
|
events: '本次会话 {{n}} 条事件',
|
||||||
|
clear: '清空',
|
||||||
|
},
|
||||||
|
rpc: {
|
||||||
|
toggleTip: '链读取 RPC 端点 — 点击设置自己的(仅存于本浏览器)',
|
||||||
|
placeholder: 'https://… JSON-RPC 地址(链 4663)· 留空 = 默认',
|
||||||
|
apply: '应用',
|
||||||
|
checking: '检测中…',
|
||||||
|
reset: '重置',
|
||||||
|
notHttp: '不是 http(s) 地址',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
label: 'theme:',
|
||||||
|
switchTip: '切换到 {{name}} 主题',
|
||||||
|
},
|
||||||
|
lang: { label: 'lang:' },
|
||||||
|
rbar: {
|
||||||
|
inRange: '区间内 · 已穿过 {{pct}}% · 带宽 ±{{band}}%',
|
||||||
|
outRise: '超出区间 · 价格需上涨 {{pct}}% 才能回到区间',
|
||||||
|
outFall: '超出区间 · 价格需下跌 {{pct}}% 才能回到区间',
|
||||||
|
orderFilled: '订单已完全成交 · 100% 持有 {{sym}} — 提取以锁定',
|
||||||
|
orderFilling: '订单成交中 · {{sym}} 已卖出 {{pct}}% · 价格回落会回吐',
|
||||||
|
orderWaitRise: '订单等待中 · 价格需上涨 {{pct}}% 开始成交',
|
||||||
|
orderWaitFall: '订单等待中 · 价格需下跌 {{pct}}% 开始成交',
|
||||||
|
toLow: '→ 下界',
|
||||||
|
fromHigh: '上界 ←',
|
||||||
|
flipTip: '翻转价格方向',
|
||||||
|
ticksTip: 'tick [{{lo}}, {{hi}}] · 当前 {{tick}}',
|
||||||
|
},
|
||||||
|
tx: {
|
||||||
|
confirm: '{{label}} — 请在钱包确认…',
|
||||||
|
pending: '{{label}} — 等待上链',
|
||||||
|
reverted: '{{label}} — 已回滚',
|
||||||
|
ok: '{{label}} — 成功 @ 块 {{n}}',
|
||||||
|
approve: '授权 {{sym}}',
|
||||||
|
received: '到账 {{amt}} UP',
|
||||||
|
swapToEth: '兑换 → ETH',
|
||||||
|
hintKyberMinOut: '触发 kyber 最小到账保护 — 报价到发送之间价格变了;调高滑点或重试',
|
||||||
|
hintPS: '价格滑点 — 池价超出容差,重试即可',
|
||||||
|
hintV2Ratio: 'v2 池比例在联动后变了 — 重新输入金额再试',
|
||||||
|
hintSTF: '代币转账失败 — 检查授权/余额',
|
||||||
|
hintNP: '未获此 token id 的授权',
|
||||||
|
hintDeadline: '超过截止时间 — 重试',
|
||||||
|
},
|
||||||
|
kyber: {
|
||||||
|
routerMismatch: '路由地址不符 {{addr}} — 已中止',
|
||||||
|
badValue: '交易 value 异常 {{got}}(应为 {{want}})— 已中止',
|
||||||
|
buildDeviates: '构建结果偏离报价(in {{in}}, out {{out}})— 已中止',
|
||||||
|
},
|
||||||
|
pools: {
|
||||||
|
searchPlaceholder: '搜索交易对 / 符号 / 代币 0x… / 池 0x… ( / )',
|
||||||
|
protoAll: '全部',
|
||||||
|
protoAllTip: '所有协议',
|
||||||
|
protoUp33Tip: '仅 up33 ve(3,3) 池',
|
||||||
|
protoV3Tip: '仅 uniswap v3 池',
|
||||||
|
protoV2Tip: '仅 uniswap v2 池',
|
||||||
|
hideDust: '隐藏 <$1K',
|
||||||
|
hideDustTip: '隐藏 TVL 低于 $1k 的池 — uniswap 目录 95% 是 meme 灰尘池',
|
||||||
|
mine: '● 我的 ({{n}})',
|
||||||
|
loading: '> 正在从工厂枚举池子',
|
||||||
|
scanFailed: '> 池扫描失败: {{err}}',
|
||||||
|
statShown: '显示 {{n}}',
|
||||||
|
statUp33: 'up33 {{n}}',
|
||||||
|
statUniswap: 'uniswap',
|
||||||
|
statCatalog: '目录 {{n}}',
|
||||||
|
statMatch: '匹配 {{n}}',
|
||||||
|
statTop: 'TVL 前 {{n}}',
|
||||||
|
statVol: '量',
|
||||||
|
uniScanFailed: 'uniswap 扫描失败: {{err}}',
|
||||||
|
fallbackNote: '索引器离线 — dexscreener 兜底(v3 前 30)',
|
||||||
|
spoofDropped: '拦截 {{n}} 个伪造池',
|
||||||
|
thPair: '交易对',
|
||||||
|
thPrice: '价格 / 储备',
|
||||||
|
thTvl: 'TVL',
|
||||||
|
thVol: '24H 量',
|
||||||
|
thFees: '24H 费',
|
||||||
|
thFeeApr: '费率 APR',
|
||||||
|
thRewards: '排放奖励',
|
||||||
|
sortTip: '点击排序',
|
||||||
|
mineDotTip: '你在此池有仓位',
|
||||||
|
gauge: 'gauge',
|
||||||
|
killed: '已停用',
|
||||||
|
noGauge: '无 gauge',
|
||||||
|
feeTipUniV2: '无抽成 — 0.30% 手续费滚入储备(LP 自动复利)',
|
||||||
|
feeTipUniV3: '无 ve(3,3) 抽成 — LP 保留 100% 交易手续费',
|
||||||
|
rewardsTip:
|
||||||
|
'仅 up33:质押 LP 的 UP 收益率(封顶后实时速率)· 每周排放 · gauge 投票占比 — uniswap 池无排放,LP 改拿手续费。UP33 过滤视图有完整明细。',
|
||||||
|
upWk: '{{n}} UP/周',
|
||||||
|
noEmissions: '无排放',
|
||||||
|
vote: '投票 {{n}}%',
|
||||||
|
stakedPct: '{{n}}% 已质押',
|
||||||
|
addLp: '+ LP',
|
||||||
|
footnote:
|
||||||
|
'<0>费率 APR</0> = 未质押 LP 手续费收益 · <1>排放奖励</1> = up33 质押 UP 排放 APR · 一个仓位只赚其一,不能兼得',
|
||||||
|
footnoteTip:
|
||||||
|
'数据源:uniswap 用池索引器(链上推导 TVL + geckoterminal 量)· up33 用 dexscreener + goldsky 子图 · 24h 费 = 量 × 费率(毛)· 单利,不复利',
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
fund: '资金',
|
||||||
|
fundPair: '双币',
|
||||||
|
fundPairTip: '按池子比例同时提供两种代币',
|
||||||
|
fundZap: '⚡ ZAP — 单币',
|
||||||
|
fundZapTip: '只持有一边?zap 先把合适的份额换成另一种代币,再存入 — 逐步执行',
|
||||||
|
range: '区间',
|
||||||
|
full: '全区间',
|
||||||
|
pctCustom: '± % …',
|
||||||
|
pctCustomTip: '对称区间,自定义百分比',
|
||||||
|
above: '↑ 上方',
|
||||||
|
aboveTip: '现价上方单边 — 只存 {{sym}},价格涨入后开始赚钱(涨了才卖)',
|
||||||
|
below: '↓ 下方',
|
||||||
|
belowTip: '现价下方单边 — 只存 {{sym}},价格跌入后开始赚钱(跌了才接)',
|
||||||
|
price: '价格',
|
||||||
|
priceTip: '输入精确价格上下界',
|
||||||
|
ticks: 'TICKS',
|
||||||
|
ticksTip: '原始 tick 上下界',
|
||||||
|
priceLo: '下界价格',
|
||||||
|
priceHi: '上界价格',
|
||||||
|
priceUnits: '{{quote}}/{{base}} · 对齐 ts{{ts}}',
|
||||||
|
tickLower: 'tickLower',
|
||||||
|
tickUpper: 'tickUpper',
|
||||||
|
spacing: '间距 {{ts}}',
|
||||||
|
limitHint: '想卖固定数量?',
|
||||||
|
limitHintLink: '兑换 → 限价',
|
||||||
|
limitHintRest: '会作为可跟踪的区间订单挂出',
|
||||||
|
aboveNote: '价格在区间上方 — 仅 token1',
|
||||||
|
belowNote: '价格在区间下方 — 仅 token0',
|
||||||
|
projected: '预估',
|
||||||
|
projOut: '区间不含当前价格 — 价格进入前不产生任何收益',
|
||||||
|
projDep: '存入 ≈ {{usd}}',
|
||||||
|
projFeeApr: '费率 APR',
|
||||||
|
projIfUnstaked: '(不质押时)',
|
||||||
|
projEmitApr: '排放 APR',
|
||||||
|
projIfStaked: '(质押时)',
|
||||||
|
projShare: '占活跃流动性 {{pct}}% · 区间内有效',
|
||||||
|
mint: '铸造仓位',
|
||||||
|
mintHintUni: 'NFT 进入钱包 — 在仓位页管理(uniswap 无质押,手续费归你)',
|
||||||
|
mintHintUp33: 'NFT 进入钱包 — 去仓位页质押赚 UP',
|
||||||
|
addLiquidity: '添加流动性',
|
||||||
|
v2Hint: '数量按储备自动配平 · 最小值容差 {{slip}}%',
|
||||||
|
v2HintUni: 'LP 代币进入钱包(uniswap v2 无质押,手续费在储备中自动复利)',
|
||||||
|
v2HintUp33: 'LP 代币进入钱包,去仓位页质押',
|
||||||
|
setRangeFirst: '> 先设定有效区间 — zap 拆分取决于它。',
|
||||||
|
stepAddV2: '添加 v2 {{pair}} 流动性',
|
||||||
|
stepAdd: '添加 {{pair}} 流动性',
|
||||||
|
stepMint: '铸造 {{kind}} {{pair}} [{{lo}},{{hi}}]',
|
||||||
|
},
|
||||||
|
zap: {
|
||||||
|
zapIn: 'ZAP 输入',
|
||||||
|
ethTip: '原生 ETH — 第 1 步先包装成 WETH',
|
||||||
|
slip: '滑点',
|
||||||
|
slipHint: '兑换腿最小到账 · 存入最小值按 1% 带边计算',
|
||||||
|
solving: '> 正在通过 kyber 求解拆分',
|
||||||
|
cantPlan: '> 无法规划: {{err}}',
|
||||||
|
planTitle: 'ZAP 计划',
|
||||||
|
split: '拆分',
|
||||||
|
keepAll: '全部保留 {{amt}} {{sym}}',
|
||||||
|
keepSwap: '保留 {{keep}} + 兑换 {{swap}} {{sym}}',
|
||||||
|
splitSdSingle: '当前价格下该区间只收这一种代币 — 无需兑换',
|
||||||
|
splitSd: '按当前价格的存入比例精确配平两份',
|
||||||
|
swapRow: '兑换',
|
||||||
|
swapMin: '最少 {{amt}} @ {{slip}}%',
|
||||||
|
impact: '价格影响 {{pct}}%',
|
||||||
|
impactOff: '价格影响不可用(美元标价不可靠 — 以最小到账为准)',
|
||||||
|
via: '经 {{route}}',
|
||||||
|
depositRow: '存入',
|
||||||
|
dustSd: '预计零头 {{dust}} 留在钱包(不会发送)',
|
||||||
|
actualSd: '兑换侧按实际到账存入,不按报价',
|
||||||
|
bandWarn: '兑换影响 {{impact}}% 相对 ±{{band}}% 区间过大 — 会产生零头;建议放宽区间或减少数量',
|
||||||
|
run: '⚡ 执行 ZAP',
|
||||||
|
runTx: '⚡ 执行 ZAP({{n}} 笔交易)',
|
||||||
|
runHint: '任一步失败即停 — 资金都在你钱包里,不会悬空',
|
||||||
|
halted: '在第 {{n}} 步停止 — 已完成的步骤都是普通钱包余额。解决后重新执行(会按当前余额重新规划),或用双币模式收尾。',
|
||||||
|
done: '✓ zap 完成 — 流动性已生效。',
|
||||||
|
replanFailed: 'zap 重新规划失败: {{err}}',
|
||||||
|
orderGrows: '这会加大未平的区间订单 — 同区间、同均价、更大规模。',
|
||||||
|
errAmount: '请输入数量',
|
||||||
|
errNotInPool: '该代币不在此池中',
|
||||||
|
errNoReserves: '池子无储备 — 用双币模式初始注入',
|
||||||
|
errNoPrice: '池价格不可用',
|
||||||
|
errZeroQuote: 'kyber 报价为零 — 池/路由太薄',
|
||||||
|
errNoRoute: '对手代币没有 kyber 路由',
|
||||||
|
errTooSmall: '数量对该区间来说太小',
|
||||||
|
stWrap: '包装 {{amt}} ETH → WETH',
|
||||||
|
stApproveKyber: '授权 {{sym}} → kyber 路由',
|
||||||
|
stApproveSpender: '授权 {{sym}} → {{spender}}',
|
||||||
|
spenderRouter: '路由',
|
||||||
|
spenderNpm: '仓位管理器',
|
||||||
|
stSwap: '兑换 {{amt}} {{sym}} → ≈{{out}} {{outSym}}',
|
||||||
|
stIncrease: '加仓 #{{id}}',
|
||||||
|
stMint: '铸造仓位',
|
||||||
|
stAddLiquidity: '添加流动性',
|
||||||
|
halt: 'zap 已停止 — {{msg}}',
|
||||||
|
haltRequote: '重新报价失败: {{err}}',
|
||||||
|
haltPriceMoved: '价格自预览后变动(现 {{now}},预览 {{prev}})— 请核对后重新执行',
|
||||||
|
haltTokenOut: '路由 tokenOut {{addr}} 不是池子对手代币 — 已中止',
|
||||||
|
haltNoTransfer: '兑换成功但未找到对手代币转账 — 请先排查再重试',
|
||||||
|
haltNothing: '没有可存入的数量',
|
||||||
|
haltDepositSmall: '当前价格下存入量对该区间太小(已换的代币在你钱包里)',
|
||||||
|
stakeHint: 'zap 完成 — 质押仓位开始赚 UP',
|
||||||
|
stakeHintAction: '仓位 →',
|
||||||
|
},
|
||||||
|
pos: {
|
||||||
|
connectPrompt: '> 连接钱包以枚举仓位。还没有仓位?',
|
||||||
|
browsePools: '去池子页看看 →',
|
||||||
|
scanning: '> 正在扫描仓位',
|
||||||
|
scanFailed: '> 仓位扫描失败: {{err}}',
|
||||||
|
empty: '> {{addr}}… 名下没有 LP 仓位。',
|
||||||
|
emptyCta: '去池子页添加流动性 →',
|
||||||
|
lpValue: 'LP 总值',
|
||||||
|
lpValueSub: '{{cl}} CL · {{v2}} v2 · {{staked}} 已质押',
|
||||||
|
lpValueFees: '未领手续费 ≈{{usd}}',
|
||||||
|
lpValueUnpriced: '{{n}} 个未定价',
|
||||||
|
pendingUp: '待领 UP',
|
||||||
|
upPerDay: '+{{n}} UP/天',
|
||||||
|
upPerDayUsd: '(≈{{usd}}/天)',
|
||||||
|
claimAll: '全部领取({{n}} 笔)',
|
||||||
|
nothingClaimable: '暂无可领取',
|
||||||
|
rangeStatus: '区间状态',
|
||||||
|
outOfRangeN: '{{n}} 个超出区间',
|
||||||
|
allInRange: '全部在区间内',
|
||||||
|
rangeStatusSub: '超出区间的 CL 没有任何收益',
|
||||||
|
ordersNotCounted: '区间订单不计入',
|
||||||
|
rangeOrders: '区间订单',
|
||||||
|
ordersOpen: '{{n}} 个挂单',
|
||||||
|
ordersFilled: '{{n}} 个已完全成交 — 提取以锁定 ↓',
|
||||||
|
ordersNone: '尚无成交 — 卖出代币升值时逐步成交',
|
||||||
|
sectionCl: 'CL 仓位({{n}})',
|
||||||
|
sectionV2: 'V2 仓位({{n}})',
|
||||||
|
noCl: '暂无 CL 仓位。',
|
||||||
|
noClCta: '去池子页开一个 →',
|
||||||
|
noV2: '暂无 v2 LP。',
|
||||||
|
noV2Cta: '去池子页添加 →',
|
||||||
|
staked: '已质押',
|
||||||
|
wallet: '钱包',
|
||||||
|
limitBadge: '限价 {{sell}}→{{buy}}',
|
||||||
|
claimUp: '领取 UP',
|
||||||
|
claimUpTip: '领取待发的 UP 排放',
|
||||||
|
unstake: '解押',
|
||||||
|
unstakeTip: '解押后才能调整流动性或领取手续费 — 解押会自动领取待发 UP',
|
||||||
|
stake: '质押',
|
||||||
|
stakeTip: '把 NFT 质押进 gauge 赚 UP(质押期间手续费归投票者)',
|
||||||
|
collectFees: '领取手续费',
|
||||||
|
collectFeesTip: '领取未领取的交易手续费',
|
||||||
|
inc: '+ 加仓',
|
||||||
|
incTip: '在同一区间追加流动性 — 区间铸造时固定,不可移动',
|
||||||
|
dec: '− 减仓',
|
||||||
|
withdraw: '全部撤出',
|
||||||
|
withdrawConfirm: '确认?',
|
||||||
|
withdrawTip: '移除 100% 流动性并领取全部(双击确认)',
|
||||||
|
value: '价值',
|
||||||
|
noAnchor: '— 无价格锚',
|
||||||
|
holds: '持有',
|
||||||
|
pendingUpK: '待领 UP',
|
||||||
|
fees: '未领手续费',
|
||||||
|
levyNote: '(10% 未质押抽成已扣除)',
|
||||||
|
earning: '收益中',
|
||||||
|
earnEmit: '≈ {{n}} UP/天',
|
||||||
|
earnUsdDay: '({{usd}}/天)',
|
||||||
|
earnApr: 'APR ≈ {{apr}}',
|
||||||
|
earnShareStaked: '占质押流动性 {{share}}',
|
||||||
|
earnShareGauge: '占 gauge {{share}}',
|
||||||
|
earnOutStaked: '0 — 超出区间',
|
||||||
|
earnOutStakedTip: '质押 CL 只在价格处于区间内时发 UP',
|
||||||
|
earnEnded: '0 — 本 epoch 无 UP 排放流入',
|
||||||
|
earnFeeApr: '费率 APR ≈',
|
||||||
|
earnShareActive: '占活跃流动性 {{share}}',
|
||||||
|
earnSharePool: '占池子 {{share}}',
|
||||||
|
earnWhileInRange: '区间内有效',
|
||||||
|
earnUnknown: '手续费累积中 — 无 24h 量数据,无法估算收益率',
|
||||||
|
earnOut: '0 — 超出区间',
|
||||||
|
earnEmpty: '空仓 — 仅剩未领手续费',
|
||||||
|
earningStaked: '收益中(质押)',
|
||||||
|
earningWallet: '收益中(钱包)',
|
||||||
|
orderRow: '区间订单 {{sell}} → {{buy}}:',
|
||||||
|
orderLockIn: '提取 → 锁定 {{sym}}',
|
||||||
|
orderClose: '立即平仓(部分成交)',
|
||||||
|
orderCancel: '取消 — 拿回 {{sym}}',
|
||||||
|
remove: '移除',
|
||||||
|
removeHint: '减仓 + 领取(2 笔交易),最小值容差 {{slip}}%',
|
||||||
|
incRange: '区间',
|
||||||
|
incRangeSd: '铸造时固定 — 追加只是在同一区间叠加流动性,不会移动区间',
|
||||||
|
incSize: '数量',
|
||||||
|
incSizeAny: '任意',
|
||||||
|
incSizeSdSingle: '无固定总量 — 价格在区间外时单边存入',
|
||||||
|
incSizeSd: '无固定总量 — 两币比例由实时池价锁定(输入框自动联动)',
|
||||||
|
incPulls: '实际拉取',
|
||||||
|
incPullsSd: '按当前价格取用 — 多给的一侧留在钱包',
|
||||||
|
incNew: '新仓规模',
|
||||||
|
incNewReseed: '为空仓重新注入',
|
||||||
|
incNewGrow: '流动性 +{{pct}}%',
|
||||||
|
incFees: '手续费',
|
||||||
|
incFeesKept: '保留',
|
||||||
|
incFeesSd: '已累计手续费在加仓时记入仓位 — 随时可领取',
|
||||||
|
incOrder: '订单',
|
||||||
|
incOrderGrows: '变大',
|
||||||
|
incOrderSd: '这会加大未平的区间订单 — 同区间、同均价、更大规模',
|
||||||
|
incBtn: '加仓',
|
||||||
|
incMins: "最小值按 {{slip}}% 带边计算 — 上链前价格再动会以 'PSC' 回滚",
|
||||||
|
v2StakeAll: '全部质押',
|
||||||
|
v2UnstakeAll: '全部解押',
|
||||||
|
v2ClaimFees: '领取手续费',
|
||||||
|
v2Total: '合计',
|
||||||
|
v2WalletLp: '钱包 LP',
|
||||||
|
v2StakedLp: '质押 LP',
|
||||||
|
v2Claimable: '可领手续费',
|
||||||
|
v2RemoveOf: '按钱包 LP 计',
|
||||||
|
v2RemoveStakedNote: '— 质押的 LP 需先解押',
|
||||||
|
stClaim: '领取 UP #{{id}}',
|
||||||
|
stClaimPair: '领取 UP({{pair}})',
|
||||||
|
stClaimV2: '领取 UP {{pair}}',
|
||||||
|
stApproveNft: '授权 NFT #{{id}} 给 gauge',
|
||||||
|
stStake: '质押 #{{id}}',
|
||||||
|
stUnstake: '解押 #{{id}}',
|
||||||
|
stCollect: '领取手续费 #{{id}}',
|
||||||
|
stDecrease: '减仓 #{{id}} {{pct}}%',
|
||||||
|
stCollectAll: '领取 #{{id}}(本金 + 手续费)',
|
||||||
|
stNothingRemove: '没有可移除的',
|
||||||
|
stStakeLp: '质押 {{pair}} LP',
|
||||||
|
stUnstakeLp: '解押 {{pair}} LP',
|
||||||
|
stClaimPoolFees: '领取池手续费({{pair}})',
|
||||||
|
stRemoveLp: '移除 {{pct}}% {{pair}} LP',
|
||||||
|
stNoWalletLp: '钱包无 LP — 先解押',
|
||||||
|
},
|
||||||
|
swap: {
|
||||||
|
mode: '模式',
|
||||||
|
market: '市价',
|
||||||
|
limit: '限价 · 挂 LP 卖出',
|
||||||
|
limitTip: '用单边 LP(CL 区间订单)卖出代币',
|
||||||
|
loadingTokens: '> 加载代币列表…',
|
||||||
|
from: '卖出',
|
||||||
|
to: '买入',
|
||||||
|
flipTip: '互换方向',
|
||||||
|
rate: '汇率: 1 {{a}} = {{n}} {{b}}',
|
||||||
|
rateTip: '点击反转',
|
||||||
|
quotes: '报价',
|
||||||
|
enterAmount: '输入数量以获取两路报价。',
|
||||||
|
kyberAgg: 'KYBER 聚合',
|
||||||
|
up33Native: 'UP33 原生',
|
||||||
|
unavailable: '不可用: {{err}}',
|
||||||
|
gas: 'gas {{usd}}',
|
||||||
|
best: '最优',
|
||||||
|
viaV2: '经 v2 {{kind}} {{fee}}%',
|
||||||
|
stable: '稳定',
|
||||||
|
volatile: '波动',
|
||||||
|
viaCl: '经 CL {{fee}}% ts{{ts}}',
|
||||||
|
bpsVsKyber: '对比 kyber {{bps}} bps',
|
||||||
|
noNativePool: '该交易对没有直接的 UP33 池',
|
||||||
|
needsWeth: '需要 WETH — 先包装',
|
||||||
|
slippage: '滑点',
|
||||||
|
refresh: '↻ 刷新',
|
||||||
|
execVia: '通过 {{route}} 执行 →',
|
||||||
|
noRoute: '无路由',
|
||||||
|
minReceived: '最少到账: {{amt}} {{sym}} @ {{slip}}%',
|
||||||
|
nativeWethNote: '原生路由到账的是 WETH(不是 ETH)',
|
||||||
|
wrapBtn: '包装 {{amt}} ETH',
|
||||||
|
unwrapBtn: '解包 {{amt}} WETH',
|
||||||
|
wrapNote: '1:1,无费用 — WETH9 {{addr}}',
|
||||||
|
stWrap: '包装 {{amt}} ETH → WETH',
|
||||||
|
stUnwrap: '解包 {{amt}} WETH → ETH',
|
||||||
|
stSwapKyber: '兑换 {{amt}} {{a}} → {{b}} [KYBER]',
|
||||||
|
stSwapV2: '兑换 {{amt}} {{a}} → {{b}} [UP33 v2]',
|
||||||
|
stSwapCl: '兑换 {{amt}} {{a}} → {{b}} [UP33 CL ts{{ts}}]',
|
||||||
|
errNoQuote: 'kyber 报价不可用',
|
||||||
|
errNeedsWeth: 'UP33 原生路由需要 WETH — 先把 ETH 包装成 WETH(选择 ETH→WETH)',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
intro: '> 做挂单方,不做吃单方:在市场价之外挂单边 LP — 不付交易费,成交过程还赚手续费',
|
||||||
|
loadingPools: '> 加载池子',
|
||||||
|
noPools: '> 没有已初始化的 CL 池。',
|
||||||
|
sell: '卖出',
|
||||||
|
for: '换成',
|
||||||
|
pool: '池子',
|
||||||
|
noPairs: '没有与 {{sym}} 配对的 CL 池 — 换一个代币',
|
||||||
|
noPoolFor: '{{pair}} 没有 CL 池。',
|
||||||
|
market: '市价: 1 {{sell}} =',
|
||||||
|
poolChip: '池 CL ts{{ts}} {{fee}}%',
|
||||||
|
sellBand: '卖出区间',
|
||||||
|
explainTip: '什么是区间?',
|
||||||
|
tight: '贴市 · 1 TICK',
|
||||||
|
tightTip: '贴着市场的最窄区间 — 一个 tick 间距;第一个上涨 tick 就开始成交,手续费捕获最大',
|
||||||
|
custom: '自定义',
|
||||||
|
aboveMarket: '% 高于市价',
|
||||||
|
exTitle: '区间 = 阶梯式退出',
|
||||||
|
exStart: '起点',
|
||||||
|
exStartSd: '第一批 {{sym}} 卖出的位置 — 越近越早成交,但回调也越容易回吐',
|
||||||
|
exEnd: '终点',
|
||||||
|
exEndSd: '最后一批 {{sym}} 卖出的位置 — 区间越宽阶梯越平滑,赚手续费的时间越长',
|
||||||
|
exAvg: '均价',
|
||||||
|
exAvgSd: '起点 × 终点的几何平均 — 你的混合退出溢价',
|
||||||
|
exGrid: '网格',
|
||||||
|
exGridSd: '区间边界只能落在 {{ts}}-tick 线上(约 {{pct}}% 步长)— 贴市是最近的合法区间;更小的自定义值会吸附到它',
|
||||||
|
orderTitle: '订单',
|
||||||
|
fillStarts: '开始成交',
|
||||||
|
fullySold: '全部卖出',
|
||||||
|
avgFill: '平均成交',
|
||||||
|
priceEq: '1 {{sell}} = {{px}} {{buy}}',
|
||||||
|
priceApprox: '1 {{sell}} ≈ {{px}} {{buy}} · √(起点·终点)',
|
||||||
|
band: '区间',
|
||||||
|
bandSd: 'tick 网格 ts{{ts}} · 约 {{pct}}% 步长',
|
||||||
|
bandTight: '最窄合法区间',
|
||||||
|
bandSnaps: '≡ 贴市 — 小于间距的输入吸附到同一区间',
|
||||||
|
projTitle: '预估 · 完全成交',
|
||||||
|
projSell: '卖出',
|
||||||
|
projSellSd: '@ 均价 {{px}} {{buy}}',
|
||||||
|
projVsMarket: '较市价 +{{pct}}%',
|
||||||
|
projReceive: '收到',
|
||||||
|
projWhenFilled: '完全成交时',
|
||||||
|
projFeeIncome: '手续费收入',
|
||||||
|
projFeeIncomeSd: '价格在区间内期间 · 按当前交易量占活跃流动性 {{share}}%',
|
||||||
|
projFeePct: '成交期间穿过你区间的交易量按此比例计费',
|
||||||
|
feesTitle: '费用 · 挂单 VS 吃单',
|
||||||
|
thisOrder: '本订单',
|
||||||
|
makerSd: '挂单方 — 不付交易费,成交时还赚手续费',
|
||||||
|
marketSwap: '市价兑换',
|
||||||
|
takerSd: '吃单方 — 池子费付给 LP',
|
||||||
|
takerLoss: '吃单方 — 此规模约损失 {{usd}}',
|
||||||
|
mechTitle: '机制',
|
||||||
|
mechFills: '成交',
|
||||||
|
mechFillsSd: '价格向上穿过区间时逐步转换为 {{buy}}',
|
||||||
|
mechUnfills: '回吐',
|
||||||
|
mechUnfillsSd: '价格回落会转换回来 — 不是挂单簿订单,没有自动执行',
|
||||||
|
mechAfter: '成交后',
|
||||||
|
mechWithdraw: '提取',
|
||||||
|
mechAfterSd: '→ 一键锁定 · 限价徽章,实时成交 %',
|
||||||
|
mechStaking: '质押',
|
||||||
|
mechDont: '不要',
|
||||||
|
mechStakingSd: '质押的 LP 会放弃交易手续费 — 订单保持不质押',
|
||||||
|
invalidBand: '输入有效区间(起点% < 终点%,都 > 0)。',
|
||||||
|
place: '挂出区间订单 →',
|
||||||
|
placeHint: '最多 2 笔交易: 精确授权 + 铸造 · 无自动执行',
|
||||||
|
bandEntered: '价格已进入你的区间 — 选一个离市场更远的区间',
|
||||||
|
stPlace: '挂区间订单: 卖 {{amt}} {{sell}} → {{buy}}',
|
||||||
|
orderLive: '区间订单 #{{id}} 已挂出 — {{sym}} 升值时逐步成交',
|
||||||
|
track: '在仓位页跟踪',
|
||||||
|
},
|
||||||
|
}
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
// APR math — pool-level columns AND per-position add-LP simulation.
|
||||||
|
//
|
||||||
|
// ve(3,3) ground rules:
|
||||||
|
// fees -> UNSTAKED LPs only (CL pays a 10% default levy); staked LPs' fees go to voters
|
||||||
|
// UP -> STAKED LPs only, pro-rata ACTIVE (in-range) staked liquidity, post-cap rewardRate
|
||||||
|
// A position earns one or the other, never both.
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import { sqrtPriceToPrice } from './clmath'
|
||||||
|
import { nowSec } from './format'
|
||||||
|
import type { PoolStat } from './poolstats'
|
||||||
|
import type { ClPool, Pool, V2Pool } from '../types'
|
||||||
|
|
||||||
|
const YEAR = 31_536_000
|
||||||
|
|
||||||
|
export function fees24Of(p: Pool, stat?: PoolStat): number | null {
|
||||||
|
if (stat?.vol24hUsd == null) return null
|
||||||
|
const feePct = p.kind === 'v2' ? p.feeBps / 100 : p.feePpm / 10_000
|
||||||
|
return (stat.vol24hUsd * feePct) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pool-average fee APR for an UNSTAKED LP (net of the CL unstaked levy) */
|
||||||
|
export function feeAprOf(p: Pool, stat?: PoolStat): number | null {
|
||||||
|
if (stat?.vol24hUsd == null || stat.liqUsd == null || stat.liqUsd <= 0) return null
|
||||||
|
const feeFrac = p.kind === 'v2' ? p.feeBps / 10_000 : p.feePpm / 1e6
|
||||||
|
const keep = p.kind === 'cl' ? 1 - p.unstakedFeePpm / 1e6 : 1
|
||||||
|
return ((stat.vol24hUsd * feeFrac * keep * 365) / stat.liqUsd) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stakedShareOf(p: Pool): number {
|
||||||
|
if (p.kind === 'v2') return p.totalSupply > 0n ? Number(p.gaugeTotalSupply) / Number(p.totalSupply) : 0
|
||||||
|
return p.liquidity > 0n ? Number(p.stakedLiquidity) / Number(p.liquidity) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmitting(p: Pool): boolean {
|
||||||
|
return p.rewardRate > 0n && p.periodFinish > BigInt(nowSec())
|
||||||
|
}
|
||||||
|
|
||||||
|
function upPerYearUsd(p: Pool, upUsd: number): number {
|
||||||
|
return (Number(p.rewardRate) / 1e18) * YEAR * upUsd
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pool-average emissions APR for a STAKED LP (Infinity = ~zero staked TVL) */
|
||||||
|
export function emitAprOf(p: Pool, stat: PoolStat | undefined, upUsd: number | undefined): number | null {
|
||||||
|
if (!upUsd || stat?.liqUsd == null || stat.liqUsd <= 0) return null
|
||||||
|
if (!isEmitting(p)) return null
|
||||||
|
const stakedTvl = stat.liqUsd * stakedShareOf(p)
|
||||||
|
if (stakedTvl < 0.01) return Infinity
|
||||||
|
return (upPerYearUsd(p, upUsd) / stakedTvl) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtApr(x: number): string {
|
||||||
|
if (Number.isNaN(x)) return '—'
|
||||||
|
if (!Number.isFinite(x)) return '∞'
|
||||||
|
// dust-TVL pools produce absurd APRs — cap the string so table columns
|
||||||
|
// never get stretched by a meaningless number
|
||||||
|
if (x >= 10_000) return '>9,999%'
|
||||||
|
if (x >= 1000) return Math.round(x).toLocaleString('en-US') + '%'
|
||||||
|
if (x >= 10) return x.toFixed(0) + '%'
|
||||||
|
return x.toFixed(2) + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** USD prices of a CL pool's two tokens via USDG($1) / WETH / UP anchors */
|
||||||
|
export function clTokenUsd(
|
||||||
|
pool: ClPool,
|
||||||
|
dec0: number,
|
||||||
|
dec1: number,
|
||||||
|
upUsd?: number,
|
||||||
|
wethUsd?: number | null,
|
||||||
|
): { p0: number; p1: number } | null {
|
||||||
|
const anchors: Record<string, number | undefined> = {
|
||||||
|
[ADDR.USDG.toLowerCase()]: 1,
|
||||||
|
[ADDR.WETH.toLowerCase()]: wethUsd ?? undefined,
|
||||||
|
[ADDR.UP.toLowerCase()]: upUsd,
|
||||||
|
}
|
||||||
|
const P = sqrtPriceToPrice(pool.sqrtPriceX96, dec0, dec1) // token1 per 1 token0
|
||||||
|
if (!Number.isFinite(P) || P <= 0) return null
|
||||||
|
const a0 = anchors[pool.token0.toLowerCase()]
|
||||||
|
const a1 = anchors[pool.token1.toLowerCase()]
|
||||||
|
if (a0 !== undefined && a0 > 0) return { p0: a0, p1: a0 / P }
|
||||||
|
if (a1 !== undefined && a1 > 0) return { p0: P * a1, p1: a1 }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddSim = {
|
||||||
|
depositUsd: number
|
||||||
|
feeApr: number // NaN when volume unknown — YOUR unstaked net fee APR while in range
|
||||||
|
emitApr: number // NaN when UP price unknown / not emitting — YOUR staked APR while in range
|
||||||
|
sharePct: number // your share of active liquidity (fee basis)
|
||||||
|
inRange: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate YOUR APRs for a prospective CL position. Unlike the pool columns
|
||||||
|
* this is position-specific: your liquidity L (from amounts × range width)
|
||||||
|
* captures concentration — a narrow range packs more L per dollar — and your L
|
||||||
|
* is added to the denominators, capturing dilution.
|
||||||
|
* fee share = L / (activeLiquidity + L) (× vol × fee × (1−levy))
|
||||||
|
* emit share = L / (stakedLiquidity + L) (× rewardRate × UP price)
|
||||||
|
* Both hold only while price stays inside your range.
|
||||||
|
*/
|
||||||
|
export function simulateClAdd(args: {
|
||||||
|
pool: ClPool
|
||||||
|
tickLower: number
|
||||||
|
tickUpper: number
|
||||||
|
liquidity: bigint
|
||||||
|
amount0h: number // human units
|
||||||
|
amount1h: number
|
||||||
|
dec0: number
|
||||||
|
dec1: number
|
||||||
|
stat?: PoolStat
|
||||||
|
upUsd?: number
|
||||||
|
wethUsd?: number | null
|
||||||
|
}): AddSim | null {
|
||||||
|
const { pool, liquidity } = args
|
||||||
|
if (liquidity <= 0n) return null
|
||||||
|
const px = clTokenUsd(pool, args.dec0, args.dec1, args.upUsd, args.wethUsd)
|
||||||
|
if (!px) return null
|
||||||
|
const depositUsd = args.amount0h * px.p0 + args.amount1h * px.p1
|
||||||
|
if (!(depositUsd > 0)) return null
|
||||||
|
const inRange = pool.tick >= args.tickLower && pool.tick < args.tickUpper
|
||||||
|
if (!inRange) return { depositUsd, feeApr: 0, emitApr: 0, sharePct: 0, inRange: false }
|
||||||
|
const L = Number(liquidity)
|
||||||
|
const feeShare = L / (Number(pool.liquidity) + L)
|
||||||
|
const emitShare = L / (Number(pool.stakedLiquidity) + L)
|
||||||
|
const keep = 1 - pool.unstakedFeePpm / 1e6
|
||||||
|
const feeApr =
|
||||||
|
args.stat?.vol24hUsd == null
|
||||||
|
? NaN
|
||||||
|
: ((args.stat.vol24hUsd * 365 * (pool.feePpm / 1e6) * keep * feeShare) / depositUsd) * 100
|
||||||
|
const emitApr =
|
||||||
|
!args.upUsd || !isEmitting(pool)
|
||||||
|
? NaN
|
||||||
|
: ((upPerYearUsd(pool, args.upUsd) * emitShare) / depositUsd) * 100
|
||||||
|
return { depositUsd, feeApr, emitApr, sharePct: feeShare * 100, inRange: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate YOUR APRs for a v2 add: pool APRs diluted by your deposit. */
|
||||||
|
export function simulateV2Add(args: {
|
||||||
|
pool: V2Pool
|
||||||
|
amount0h: number
|
||||||
|
amount1h: number
|
||||||
|
dec0: number
|
||||||
|
dec1: number
|
||||||
|
stat?: PoolStat
|
||||||
|
upUsd?: number
|
||||||
|
}): AddSim | null {
|
||||||
|
const { pool, stat } = args
|
||||||
|
if (stat?.liqUsd == null || stat.liqUsd <= 0) return null
|
||||||
|
const r0h = Number(pool.reserve0) / 10 ** args.dec0
|
||||||
|
const r1h = Number(pool.reserve1) / 10 ** args.dec1
|
||||||
|
if (!(r0h > 0) || !(r1h > 0)) return null
|
||||||
|
// v2 pools sit ~50/50 by value — price each token off its half of TVL
|
||||||
|
const p0 = stat.liqUsd / 2 / r0h
|
||||||
|
const p1 = stat.liqUsd / 2 / r1h
|
||||||
|
const depositUsd = args.amount0h * p0 + args.amount1h * p1
|
||||||
|
if (!(depositUsd > 0)) return null
|
||||||
|
const feeApr =
|
||||||
|
stat.vol24hUsd == null
|
||||||
|
? NaN
|
||||||
|
: ((stat.vol24hUsd * 365 * (pool.feeBps / 10_000)) / (stat.liqUsd + depositUsd)) * 100
|
||||||
|
const stakedTvl = stat.liqUsd * stakedShareOf(pool)
|
||||||
|
const emitApr =
|
||||||
|
!args.upUsd || !isEmitting(pool)
|
||||||
|
? NaN
|
||||||
|
: (upPerYearUsd(pool, args.upUsd) / (stakedTvl + depositUsd)) * 100
|
||||||
|
return {
|
||||||
|
depositUsd,
|
||||||
|
feeApr,
|
||||||
|
emitApr,
|
||||||
|
sharePct: (depositUsd / (stat.liqUsd + depositUsd)) * 100,
|
||||||
|
inRange: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Concentrated-liquidity math (Uniswap v3 / Slipstream), BigInt port of TickMath
|
||||||
|
// + LiquidityAmounts. Validated against live pools by scripts/smoke.ts.
|
||||||
|
|
||||||
|
const MAX_UINT256 = (1n << 256n) - 1n
|
||||||
|
export const Q96 = 1n << 96n
|
||||||
|
export const MAX_UINT128 = (1n << 128n) - 1n
|
||||||
|
export const MIN_TICK = -887272
|
||||||
|
export const MAX_TICK = 887272
|
||||||
|
|
||||||
|
export function getSqrtRatioAtTick(tick: number): bigint {
|
||||||
|
const absTick = tick < 0 ? -tick : tick
|
||||||
|
if (absTick > MAX_TICK) throw new Error(`tick ${tick} out of range`)
|
||||||
|
let ratio =
|
||||||
|
(absTick & 0x1) !== 0
|
||||||
|
? 0xfffcb933bd6fad37aa2d162d1a594001n
|
||||||
|
: 0x100000000000000000000000000000000n
|
||||||
|
if (absTick & 0x2) ratio = (ratio * 0xfff97272373d413259a46990580e213an) >> 128n
|
||||||
|
if (absTick & 0x4) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdccn) >> 128n
|
||||||
|
if (absTick & 0x8) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0n) >> 128n
|
||||||
|
if (absTick & 0x10) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644n) >> 128n
|
||||||
|
if (absTick & 0x20) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0n) >> 128n
|
||||||
|
if (absTick & 0x40) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861n) >> 128n
|
||||||
|
if (absTick & 0x80) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053n) >> 128n
|
||||||
|
if (absTick & 0x100) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4n) >> 128n
|
||||||
|
if (absTick & 0x200) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54n) >> 128n
|
||||||
|
if (absTick & 0x400) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3n) >> 128n
|
||||||
|
if (absTick & 0x800) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9n) >> 128n
|
||||||
|
if (absTick & 0x1000) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825n) >> 128n
|
||||||
|
if (absTick & 0x2000) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5n) >> 128n
|
||||||
|
if (absTick & 0x4000) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7n) >> 128n
|
||||||
|
if (absTick & 0x8000) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6n) >> 128n
|
||||||
|
if (absTick & 0x10000) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9n) >> 128n
|
||||||
|
if (absTick & 0x20000) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604n) >> 128n
|
||||||
|
if (absTick & 0x40000) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98n) >> 128n
|
||||||
|
if (absTick & 0x80000) ratio = (ratio * 0x48a170391f7dc42444e8fa2n) >> 128n
|
||||||
|
if (tick > 0) ratio = MAX_UINT256 / ratio
|
||||||
|
// Q128.128 -> Q64.96, rounding up
|
||||||
|
return (ratio >> 32n) + ((ratio & 0xffffffffn) === 0n ? 0n : 1n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** amounts of token0/token1 a position of `liquidity` holds at price sqrtP */
|
||||||
|
export function getAmountsForLiquidity(
|
||||||
|
sqrtP: bigint,
|
||||||
|
sqrtA: bigint,
|
||||||
|
sqrtB: bigint,
|
||||||
|
liquidity: bigint,
|
||||||
|
): { amount0: bigint; amount1: bigint } {
|
||||||
|
if (sqrtA > sqrtB) [sqrtA, sqrtB] = [sqrtB, sqrtA]
|
||||||
|
if (liquidity === 0n) return { amount0: 0n, amount1: 0n }
|
||||||
|
if (sqrtP <= sqrtA) {
|
||||||
|
return { amount0: amount0For(sqrtA, sqrtB, liquidity), amount1: 0n }
|
||||||
|
}
|
||||||
|
if (sqrtP >= sqrtB) {
|
||||||
|
return { amount0: 0n, amount1: amount1For(sqrtA, sqrtB, liquidity) }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
amount0: amount0For(sqrtP, sqrtB, liquidity),
|
||||||
|
amount1: amount1For(sqrtA, sqrtP, liquidity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function amount0For(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint {
|
||||||
|
return (L * Q96 * (sqrtB - sqrtA)) / sqrtB / sqrtA
|
||||||
|
}
|
||||||
|
function amount1For(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint {
|
||||||
|
return (L * (sqrtB - sqrtA)) / Q96
|
||||||
|
}
|
||||||
|
|
||||||
|
/** max liquidity for given amounts (mirror of LiquidityAmounts.getLiquidityForAmounts) */
|
||||||
|
export function getLiquidityForAmounts(
|
||||||
|
sqrtP: bigint,
|
||||||
|
sqrtA: bigint,
|
||||||
|
sqrtB: bigint,
|
||||||
|
amount0: bigint,
|
||||||
|
amount1: bigint,
|
||||||
|
): bigint {
|
||||||
|
if (sqrtA > sqrtB) [sqrtA, sqrtB] = [sqrtB, sqrtA]
|
||||||
|
if (sqrtP <= sqrtA) return liquidity0(sqrtA, sqrtB, amount0)
|
||||||
|
if (sqrtP >= sqrtB) return liquidity1(sqrtA, sqrtB, amount1)
|
||||||
|
const l0 = liquidity0(sqrtP, sqrtB, amount0)
|
||||||
|
const l1 = liquidity1(sqrtA, sqrtP, amount1)
|
||||||
|
return l0 < l1 ? l0 : l1
|
||||||
|
}
|
||||||
|
|
||||||
|
function liquidity0(sqrtA: bigint, sqrtB: bigint, amount0: bigint): bigint {
|
||||||
|
if (sqrtB === sqrtA) return 0n
|
||||||
|
return (amount0 * sqrtA * sqrtB) / Q96 / (sqrtB - sqrtA)
|
||||||
|
}
|
||||||
|
function liquidity1(sqrtA: bigint, sqrtB: bigint, amount1: bigint): bigint {
|
||||||
|
if (sqrtB === sqrtA) return 0n
|
||||||
|
return (amount1 * Q96) / (sqrtB - sqrtA)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint/increase preview: given one input amount, derive the other side.
|
||||||
|
* Returns null when the entered token is inactive for the chosen range
|
||||||
|
* (single-sided range on the other token).
|
||||||
|
*/
|
||||||
|
export function previewDeposit(
|
||||||
|
sqrtP: bigint,
|
||||||
|
tickLower: number,
|
||||||
|
tickUpper: number,
|
||||||
|
input: bigint,
|
||||||
|
inputIsToken0: boolean,
|
||||||
|
): { amount0: bigint; amount1: bigint; liquidity: bigint } | null {
|
||||||
|
const sqrtA = getSqrtRatioAtTick(tickLower)
|
||||||
|
const sqrtB = getSqrtRatioAtTick(tickUpper)
|
||||||
|
if (sqrtP <= sqrtA) {
|
||||||
|
// price below range: token0-only
|
||||||
|
if (!inputIsToken0) return null
|
||||||
|
const L = liquidity0(sqrtA, sqrtB, input)
|
||||||
|
return { amount0: input, amount1: 0n, liquidity: L }
|
||||||
|
}
|
||||||
|
if (sqrtP >= sqrtB) {
|
||||||
|
// price above range: token1-only
|
||||||
|
if (inputIsToken0) return null
|
||||||
|
const L = liquidity1(sqrtA, sqrtB, input)
|
||||||
|
return { amount0: 0n, amount1: input, liquidity: L }
|
||||||
|
}
|
||||||
|
if (inputIsToken0) {
|
||||||
|
const L = liquidity0(sqrtP, sqrtB, input)
|
||||||
|
return { amount0: input, amount1: amount1For(sqrtA, sqrtP, L), liquidity: L }
|
||||||
|
}
|
||||||
|
const L = liquidity1(sqrtA, sqrtP, input)
|
||||||
|
return { amount0: amount0For(sqrtP, sqrtB, L), amount1: input, liquidity: L }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- price helpers (float, display only) ----------
|
||||||
|
|
||||||
|
/** price of token0 quoted in token1, human units */
|
||||||
|
export function tickToPrice(tick: number, dec0: number, dec1: number): number {
|
||||||
|
return Math.pow(1.0001, tick) * Math.pow(10, dec0 - dec1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqrtPriceToPrice(sqrtPriceX96: bigint, dec0: number, dec1: number): number {
|
||||||
|
const r = Number(sqrtPriceX96) / 2 ** 96
|
||||||
|
return r * r * Math.pow(10, dec0 - dec1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** human price (token1/token0) -> nearest tick */
|
||||||
|
export function priceToTick(price: number, dec0: number, dec1: number): number {
|
||||||
|
const raw = price / Math.pow(10, dec0 - dec1)
|
||||||
|
return Math.round(Math.log(raw) / Math.log(1.0001))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignTick(tick: number, spacing: number, mode: 'floor' | 'ceil'): number {
|
||||||
|
const q = tick / spacing
|
||||||
|
const aligned = (mode === 'floor' ? Math.floor(q) : Math.ceil(q)) * spacing
|
||||||
|
return Math.min(Math.max(aligned, Math.ceil(MIN_TICK / spacing) * spacing), Math.floor(MAX_TICK / spacing) * spacing)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullRangeTicks(spacing: number): { lower: number; upper: number } {
|
||||||
|
return {
|
||||||
|
lower: Math.ceil(MIN_TICK / spacing) * spacing,
|
||||||
|
upper: Math.floor(MAX_TICK / spacing) * spacing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tick delta approximating a +/- pct price band (e.g. 0.10 => ~10%) */
|
||||||
|
export function tickDeltaForPct(pct: number): number {
|
||||||
|
return Math.max(1, Math.round(Math.log(1 + pct) / Math.log(1.0001)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySlippage(x: bigint, bps: number): bigint {
|
||||||
|
return (x * BigInt(10_000 - bps)) / 10_000n
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slippage mins for CL liquidity ops. An in-range position's token SPLIT moves
|
||||||
|
* much faster than its value (a 1–2% price move can shift one side by 30%+),
|
||||||
|
* so flat "amount × (1−slip)" mins revert with 'PS' whenever price drifts.
|
||||||
|
* Correct bound: evaluate amounts at the EDGES of the allowed price band —
|
||||||
|
* amount0 is worst at price × (1+slip), amount1 at price × (1−slip); any price
|
||||||
|
* inside the band then satisfies both mins.
|
||||||
|
*/
|
||||||
|
export function minAmountsForLiquidity(
|
||||||
|
sqrtP: bigint,
|
||||||
|
sqrtA: bigint,
|
||||||
|
sqrtB: bigint,
|
||||||
|
liquidity: bigint,
|
||||||
|
slipBps: number,
|
||||||
|
): { amount0Min: bigint; amount1Min: bigint } {
|
||||||
|
if (sqrtA > sqrtB) [sqrtA, sqrtB] = [sqrtB, sqrtA]
|
||||||
|
const SCALE = 1_000_000_000n
|
||||||
|
const up = BigInt(Math.round(Math.sqrt(1 + slipBps / 10_000) * 1e9))
|
||||||
|
const dn = BigInt(Math.round(Math.sqrt(Math.max(0, 1 - slipBps / 10_000)) * 1e9))
|
||||||
|
let hi = (sqrtP * up) / SCALE
|
||||||
|
let lo = (sqrtP * dn) / SCALE
|
||||||
|
if (hi > sqrtB) hi = sqrtB
|
||||||
|
if (lo < sqrtA) lo = sqrtA
|
||||||
|
return {
|
||||||
|
amount0Min: getAmountsForLiquidity(hi, sqrtA, sqrtB, liquidity).amount0,
|
||||||
|
amount1Min: getAmountsForLiquidity(lo, sqrtA, sqrtB, liquidity).amount1,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { formatUnits } from 'viem'
|
||||||
|
|
||||||
|
/** significant-digit number formatting with thousands separators */
|
||||||
|
export function fmtNum(x: number, sig = 5): string {
|
||||||
|
if (!Number.isFinite(x)) return '—'
|
||||||
|
if (x === 0) return '0'
|
||||||
|
sig = Math.max(1, Math.min(sig, 21)) // toPrecision throws outside [1,100]
|
||||||
|
const neg = x < 0
|
||||||
|
const a = Math.abs(x)
|
||||||
|
let s: string
|
||||||
|
if (a >= 1) {
|
||||||
|
const intDigits = Math.floor(Math.log10(a)) + 1
|
||||||
|
const frac = Math.max(0, Math.min(sig - intDigits, 8))
|
||||||
|
s = a.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: frac })
|
||||||
|
} else {
|
||||||
|
s = a.toPrecision(sig)
|
||||||
|
if (s.includes('e')) {
|
||||||
|
const exp = Math.ceil(-Math.log10(a))
|
||||||
|
s = a.toFixed(Math.min(exp + sig, 18))
|
||||||
|
}
|
||||||
|
s = s.replace(/\.?0+$/, '')
|
||||||
|
}
|
||||||
|
return (neg ? '-' : '') + s
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtAmount(v: bigint, decimals: number, sig = 5): string {
|
||||||
|
return fmtNum(Number(formatUnits(v, decimals)), sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** compact amount for dense table cells: 24.9M, 338.4K, 12.4, 0.0421 */
|
||||||
|
export function fmtCompact(x: number): string {
|
||||||
|
if (!Number.isFinite(x)) return '—'
|
||||||
|
if (x !== 0 && Math.abs(x) < 1) return fmtNum(x, 3)
|
||||||
|
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtCompactAmount(v: bigint, decimals: number): string {
|
||||||
|
return fmtCompact(Number(formatUnits(v, decimals)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtUsd(x: number | string | undefined): string {
|
||||||
|
const n = typeof x === 'string' ? Number(x) : x
|
||||||
|
if (n === undefined || !Number.isFinite(n)) return ''
|
||||||
|
// bounded width: sub-cent USD precision is noise everywhere in this app,
|
||||||
|
// and long fractions (dust TVLs) were stretching table columns
|
||||||
|
if (n > 0 && n < 0.01) return '<$0.01'
|
||||||
|
return '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 1000 ? 0 : 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtPct(x: number, dp = 2): string {
|
||||||
|
if (!Number.isFinite(x)) return '—'
|
||||||
|
return x.toFixed(dp) + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortAddr(a: string): string {
|
||||||
|
return a.slice(0, 6) + '…' + a.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDur(seconds: number): string {
|
||||||
|
if (seconds <= 0) return '0s'
|
||||||
|
const d = Math.floor(seconds / 86400)
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`
|
||||||
|
if (m > 0) return `${m}m ${s}s`
|
||||||
|
return `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** signed bps difference of a vs b (positive = a better) */
|
||||||
|
export function bpsDiff(a: bigint, b: bigint): number {
|
||||||
|
if (b === 0n) return 0
|
||||||
|
return Number(((a - b) * 1_000_000n) / b) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowSec(): number {
|
||||||
|
return Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { Address, Hex } from 'viem'
|
||||||
|
import { ENV } from '../config/env'
|
||||||
|
|
||||||
|
/** KyberSwap sentinel for the chain's native token (ETH). */
|
||||||
|
export const NATIVE = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as Address
|
||||||
|
|
||||||
|
const HEADERS = { 'x-client-id': 'up33-terminal' }
|
||||||
|
const api = () => `${ENV.kyberBase}/${ENV.kyberChain}/api/v1`
|
||||||
|
|
||||||
|
export type KyberHop = {
|
||||||
|
pool: string
|
||||||
|
exchange: string
|
||||||
|
poolType?: string
|
||||||
|
tokenIn: string
|
||||||
|
tokenOut: string
|
||||||
|
swapAmount: string
|
||||||
|
amountOut: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KyberRouteSummary = {
|
||||||
|
tokenIn: string
|
||||||
|
tokenOut: string
|
||||||
|
amountIn: string
|
||||||
|
amountOut: string
|
||||||
|
amountInUsd?: string
|
||||||
|
amountOutUsd?: string
|
||||||
|
gas?: string
|
||||||
|
gasUsd?: string
|
||||||
|
route: KyberHop[][]
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KyberRouteData = {
|
||||||
|
routeSummary: KyberRouteSummary
|
||||||
|
routerAddress: Address
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kyberRoute(
|
||||||
|
tokenIn: Address,
|
||||||
|
tokenOut: Address,
|
||||||
|
amountIn: bigint,
|
||||||
|
opts?: { signal?: AbortSignal; applyFee?: boolean },
|
||||||
|
): Promise<KyberRouteData> {
|
||||||
|
// base arg makes path-relative bases (/kyber proxy mode) work — new URL()
|
||||||
|
// throws on a bare relative path; the base is ignored for absolute URLs
|
||||||
|
const u = new URL(`${api()}/routes`, location.origin)
|
||||||
|
u.searchParams.set('tokenIn', tokenIn)
|
||||||
|
u.searchParams.set('tokenOut', tokenOut)
|
||||||
|
u.searchParams.set('amountIn', amountIn.toString())
|
||||||
|
u.searchParams.set('gasInclude', 'true')
|
||||||
|
// optional platform fee (charged on output token, encoded by the kyber router;
|
||||||
|
// verified working on this chain — see README)
|
||||||
|
if ((opts?.applyFee ?? true) && ENV.kyberFeeBps > 0 && ENV.kyberFeeReceiver) {
|
||||||
|
u.searchParams.set('feeAmount', String(ENV.kyberFeeBps))
|
||||||
|
u.searchParams.set('chargeFeeBy', 'currency_out')
|
||||||
|
u.searchParams.set('isInBps', 'true')
|
||||||
|
u.searchParams.set('feeReceiver', ENV.kyberFeeReceiver)
|
||||||
|
}
|
||||||
|
const r = await fetch(u, { headers: HEADERS, signal: opts?.signal })
|
||||||
|
const j = await r.json().catch(() => null)
|
||||||
|
if (!r.ok || !j || j.code !== 0 || !j.data?.routeSummary) {
|
||||||
|
throw new Error(`kyber routes failed: ${j?.message ?? r.status}`)
|
||||||
|
}
|
||||||
|
return j.data as KyberRouteData
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KyberBuildData = {
|
||||||
|
data: Hex
|
||||||
|
routerAddress: Address
|
||||||
|
amountIn: string
|
||||||
|
amountOut: string
|
||||||
|
transactionValue?: string
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kyberBuild(
|
||||||
|
routeSummary: KyberRouteSummary,
|
||||||
|
sender: Address,
|
||||||
|
recipient: Address,
|
||||||
|
slippageBps: number,
|
||||||
|
): Promise<KyberBuildData> {
|
||||||
|
const r = await fetch(`${api()}/route/build`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...HEADERS, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
routeSummary,
|
||||||
|
sender,
|
||||||
|
recipient,
|
||||||
|
slippageTolerance: slippageBps,
|
||||||
|
source: 'up33-terminal',
|
||||||
|
enableGasEstimation: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const j = await r.json().catch(() => null)
|
||||||
|
if (!r.ok || !j || j.code !== 0 || !j.data?.data) {
|
||||||
|
throw new Error(`kyber build failed: ${j?.message ?? r.status}`)
|
||||||
|
}
|
||||||
|
return j.data as KyberBuildData
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KyberToken = { address: Address; symbol: string; decimals: number; name?: string }
|
||||||
|
|
||||||
|
/** Registered token list for chainId 4663 from ks-setting (seed for the picker). */
|
||||||
|
export async function kyberTokenList(): Promise<KyberToken[]> {
|
||||||
|
const base = ENV.proxied ? '/kyber-setting' : 'https://ks-setting.kyberswap.com'
|
||||||
|
const out: KyberToken[] = []
|
||||||
|
for (let page = 1; page <= 3; page++) {
|
||||||
|
const r = await fetch(`${base}/api/v1/tokens?chainIds=4663&pageSize=100&page=${page}`)
|
||||||
|
const j = await r.json().catch(() => null)
|
||||||
|
const toks: any[] = j?.data?.tokens ?? []
|
||||||
|
for (const t of toks) {
|
||||||
|
if (t?.address && t?.symbol && Number.isFinite(t?.decimals)) {
|
||||||
|
out.push({ address: t.address as Address, symbol: t.symbol, decimals: t.decimals })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toks.length < 100) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** flatten route into "55% up-v3 · 45% uniswapv3" style breakdown */
|
||||||
|
export function routeBreakdown(rs: KyberRouteSummary): string {
|
||||||
|
const amountIn = BigInt(rs.amountIn || '0')
|
||||||
|
if (amountIn === 0n || !rs.route?.length) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const path of rs.route) {
|
||||||
|
if (!path.length) continue
|
||||||
|
const first = path[0]
|
||||||
|
const pctNum = Number((BigInt(first.swapAmount || '0') * 1000n) / amountIn) / 10
|
||||||
|
const names = [...new Set(path.map((h) => h.exchange))].join('→')
|
||||||
|
parts.push(`${pctNum}% ${names}`)
|
||||||
|
}
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Gated Kyber execution: the ONLY path that turns a kyber route into a
|
||||||
|
// sendable transaction. Kyber calldata is opaque, so every build passes the
|
||||||
|
// same safety gates regardless of caller (SWAP tab, ZAP):
|
||||||
|
// 1. router whitelist — calldata goes to ENV.kyberRouter or nowhere
|
||||||
|
// 2. tx value sanity — native value exactly amountIn for ETH, else 0
|
||||||
|
// 3. build-vs-quote drift — built output must beat quote minus slippage
|
||||||
|
// 4. amountIn integrity — built tx must spend exactly what was quoted
|
||||||
|
// (a 5th, tokenOut identity, is asserted where the caller knows the pair)
|
||||||
|
import { getAddress, type Address, type Hex } from 'viem'
|
||||||
|
import { ENV } from '../config/env'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import { applySlippage } from './clmath'
|
||||||
|
import { kyberBuild, type KyberRouteSummary } from './kyber'
|
||||||
|
|
||||||
|
export type GatedKyberTx = { to: Address; data: Hex; value: bigint }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a kyber swap tx and run the safety gates. Throws Error with a
|
||||||
|
* human-readable reason on any gate failure — callers txlog it and abort.
|
||||||
|
*/
|
||||||
|
export async function buildGatedKyberTx(args: {
|
||||||
|
routeSummary: KyberRouteSummary
|
||||||
|
sender: Address
|
||||||
|
recipient: Address
|
||||||
|
slippageBps: number
|
||||||
|
/** the exact amount the caller intends to spend (must match the quote) */
|
||||||
|
amountIn: bigint
|
||||||
|
/** true when the input is native ETH (tx carries value) */
|
||||||
|
nativeIn: boolean
|
||||||
|
}): Promise<GatedKyberTx> {
|
||||||
|
const built = await kyberBuild(args.routeSummary, args.sender, args.recipient, args.slippageBps)
|
||||||
|
if (getAddress(built.routerAddress) !== ENV.kyberRouter) {
|
||||||
|
throw new Error(t('kyber.routerMismatch', { addr: built.routerAddress }))
|
||||||
|
}
|
||||||
|
const value = BigInt(built.transactionValue ?? '0')
|
||||||
|
const expected = args.nativeIn ? args.amountIn : 0n
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new Error(t('kyber.badValue', { got: value.toString(), want: expected.toString() }))
|
||||||
|
}
|
||||||
|
const quotedOut = BigInt(args.routeSummary.amountOut)
|
||||||
|
if (
|
||||||
|
BigInt(built.amountIn) !== args.amountIn ||
|
||||||
|
BigInt(built.amountOut) < applySlippage(quotedOut, args.slippageBps)
|
||||||
|
) {
|
||||||
|
throw new Error(t('kyber.buildDeviates', { in: built.amountIn, out: built.amountOut }))
|
||||||
|
}
|
||||||
|
return { to: ENV.kyberRouter, data: built.data, value }
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Range orders ("sell via LP"): a one-sided CL position parked strictly beyond
|
||||||
|
// the current price. As the market trades through the band, the deposit
|
||||||
|
// converts into the other token; fully crossed = fully sold, plus swap fees.
|
||||||
|
// NOT a resting order: if price retreats back through the band it un-fills,
|
||||||
|
// and nothing executes automatically — the owner withdraws to lock in.
|
||||||
|
//
|
||||||
|
// Closed forms for liquidity L over sqrt bounds [A,B] (verified against
|
||||||
|
// CLPool/SqrtPriceMath):
|
||||||
|
// all-token0 amount = L·(1/A − 1/B) (price at/below A)
|
||||||
|
// all-token1 amount = L·(B − A) (price at/above B)
|
||||||
|
// ⇒ full-fill average price (token1 per token0) = A·B — geometric mean of bounds
|
||||||
|
import { parseAbi, parseEventLogs, type Address, type TransactionReceipt } from 'viem'
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
|
||||||
|
export type LimitSide = 'sell0' | 'sell1'
|
||||||
|
|
||||||
|
/** selling token0 fills as token1/token0 price RISES (band above the market);
|
||||||
|
* selling token1 fills as it FALLS (band below) */
|
||||||
|
export function limitSideFor(pool: { token0: Address }, sell: Address): LimitSide {
|
||||||
|
return sell.toLowerCase() === pool.token0.toLowerCase() ? 'sell0' : 'sell1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fraction of the sell amount converted so far, 0..1 (display only) */
|
||||||
|
export function limitFillFrac(side: LimitSide, sqrtP: bigint, sqrtA: bigint, sqrtB: bigint): number {
|
||||||
|
const P = Number(sqrtP)
|
||||||
|
const A = Number(sqrtA)
|
||||||
|
const B = Number(sqrtB)
|
||||||
|
if (side === 'sell0') {
|
||||||
|
// token0 remaining ∝ 1/√P − 1/√B
|
||||||
|
if (P <= A) return 0
|
||||||
|
if (P >= B) return 1
|
||||||
|
return (1 / A - 1 / P) / (1 / A - 1 / B)
|
||||||
|
}
|
||||||
|
if (P >= B) return 0
|
||||||
|
if (P <= A) return 1
|
||||||
|
return (B - P) / (B - A)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- local bookkeeping: tokenId -> order intent (this frontend only) ----
|
||||||
|
|
||||||
|
export type LimitTag = {
|
||||||
|
sell: Address
|
||||||
|
buy: Address
|
||||||
|
sellSym: string
|
||||||
|
buySym: string
|
||||||
|
amountIn: string // raw units of the sell token
|
||||||
|
pool: Address
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'up33.limitOrders.v1'
|
||||||
|
|
||||||
|
function load(): Record<string, LimitTag> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record<string, LimitTag>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function save(m: Record<string, LimitTag>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(m))
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — tags are cosmetic */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagLimit(tokenId: bigint, tag: LimitTag) {
|
||||||
|
const m = load()
|
||||||
|
m[tokenId.toString()] = tag
|
||||||
|
save(m)
|
||||||
|
}
|
||||||
|
export function limitTagOf(tokenId: bigint): LimitTag | null {
|
||||||
|
return load()[tokenId.toString()] ?? null
|
||||||
|
}
|
||||||
|
export function untagLimit(tokenId: bigint) {
|
||||||
|
const m = load()
|
||||||
|
if (m[tokenId.toString()]) {
|
||||||
|
delete m[tokenId.toString()]
|
||||||
|
save(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const erc721TransferAbi = parseAbi([
|
||||||
|
'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** tokenId freshly minted to `user` by the CL position manager in this receipt */
|
||||||
|
export function mintedTokenId(rcpt: TransactionReceipt, user: Address): bigint | null {
|
||||||
|
const logs = parseEventLogs({ abi: erc721TransferAbi, logs: rcpt.logs, eventName: 'Transfer' })
|
||||||
|
for (const l of logs) {
|
||||||
|
if (
|
||||||
|
l.address.toLowerCase() === ADDR.CL_PM.toLowerCase() &&
|
||||||
|
l.args.from === '0x0000000000000000000000000000000000000000' &&
|
||||||
|
l.args.to?.toLowerCase() === user.toLowerCase()
|
||||||
|
) {
|
||||||
|
return l.args.tokenId ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// 24h volume / liquidity stats per pool.
|
||||||
|
// CL pools: DexScreener batch API (rolling 24h, USD, one call per 30 addrs).
|
||||||
|
// v2 pools: official Goldsky v2 subgraph, pairHourDatas summed over the last
|
||||||
|
// 24h; when the subgraph's tracked USD is 0, fall back to the USDG
|
||||||
|
// side (≈$) or the WETH side × WETH price derived from DexScreener.
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import { ENV } from '../config/env'
|
||||||
|
import type { Pool, V2Pool } from '../types'
|
||||||
|
|
||||||
|
export type PoolStat = {
|
||||||
|
vol24hUsd: number | null
|
||||||
|
liqUsd: number | null
|
||||||
|
source: 'dexscreener' | 'subgraph' | 'geckoterminal' | 'chain'
|
||||||
|
}
|
||||||
|
|
||||||
|
// in same-origin proxy mode (server deploys) these route through nginx so
|
||||||
|
// users behind restrictive networks keep TVL/volume/USD features
|
||||||
|
const DS_BASE = ENV.proxied
|
||||||
|
? '/dexscreener/latest/dex/pairs/robinhood/'
|
||||||
|
: 'https://api.dexscreener.com/latest/dex/pairs/robinhood/'
|
||||||
|
const V2_SUBGRAPH =
|
||||||
|
(ENV.proxied ? '/goldsky' : 'https://api.goldsky.com') +
|
||||||
|
'/api/public/project_cmhef02640198x7p2cz2w70u8/subgraphs/up-robinhood-v2-mainnet/0.1.0/gn'
|
||||||
|
|
||||||
|
const WETH = ADDR.WETH.toLowerCase()
|
||||||
|
const USDG = ADDR.USDG.toLowerCase()
|
||||||
|
|
||||||
|
async function fetchDexscreener(
|
||||||
|
addrs: string[],
|
||||||
|
): Promise<{ stats: Record<string, PoolStat>; wethUsd: number | null }> {
|
||||||
|
const stats: Record<string, PoolStat> = {}
|
||||||
|
let wethUsd: number | null = null
|
||||||
|
for (let i = 0; i < addrs.length; i += 30) {
|
||||||
|
const r = await fetch(DS_BASE + addrs.slice(i, i + 30).join(','))
|
||||||
|
if (!r.ok) throw new Error(`dexscreener ${r.status}`)
|
||||||
|
const j = (await r.json()) as { pairs?: any[] }
|
||||||
|
for (const p of j?.pairs ?? []) {
|
||||||
|
const addr = String(p.pairAddress ?? '').toLowerCase()
|
||||||
|
if (!addr) continue
|
||||||
|
const vol = Number(p?.volume?.h24)
|
||||||
|
const liq = Number(p?.liquidity?.usd)
|
||||||
|
stats[addr] = {
|
||||||
|
vol24hUsd: Number.isFinite(vol) ? vol : null,
|
||||||
|
liqUsd: Number.isFinite(liq) ? liq : null,
|
||||||
|
source: 'dexscreener',
|
||||||
|
}
|
||||||
|
// derive WETH/USD once from any WETH-quoted pair (priceUsd / priceNative)
|
||||||
|
if (wethUsd === null) {
|
||||||
|
const pu = Number(p?.priceUsd)
|
||||||
|
const pn = Number(p?.priceNative)
|
||||||
|
if (p?.quoteToken?.address?.toLowerCase() === WETH && pu > 0 && pn > 0) wethUsd = pu / pn
|
||||||
|
else if (p?.baseToken?.address?.toLowerCase() === WETH && pu > 0) wethUsd = pu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { stats, wethUsd }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchV2Subgraph(
|
||||||
|
v2Pools: V2Pool[],
|
||||||
|
wethUsd: number | null,
|
||||||
|
): Promise<Record<string, PoolStat>> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const q = `{
|
||||||
|
pairHourDatas(first: 1000, where: { hourStartUnix_gte: ${now - 86_400} }) {
|
||||||
|
pair { id }
|
||||||
|
hourlyVolumeUSD
|
||||||
|
hourlyVolumeToken0
|
||||||
|
hourlyVolumeToken1
|
||||||
|
}
|
||||||
|
pairs(first: 200) { id reserveUSD }
|
||||||
|
}`
|
||||||
|
const r = await fetch(V2_SUBGRAPH, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: q }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error(`v2 subgraph ${r.status}`)
|
||||||
|
const j = (await r.json()) as {
|
||||||
|
data?: {
|
||||||
|
pairHourDatas?: { pair: { id: string }; hourlyVolumeUSD: string; hourlyVolumeToken0: string; hourlyVolumeToken1: string }[]
|
||||||
|
pairs?: { id: string; reserveUSD: string }[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const byAddr = new Map(v2Pools.map((p) => [p.address.toLowerCase(), p]))
|
||||||
|
const stats: Record<string, PoolStat> = {}
|
||||||
|
// liquidity from pairs
|
||||||
|
for (const pair of j.data?.pairs ?? []) {
|
||||||
|
const addr = pair.id.toLowerCase()
|
||||||
|
if (!byAddr.has(addr)) continue
|
||||||
|
const liq = Number(pair.reserveUSD)
|
||||||
|
stats[addr] = { vol24hUsd: 0, liqUsd: Number.isFinite(liq) ? liq : null, source: 'subgraph' }
|
||||||
|
}
|
||||||
|
// rolling 24h volume from hour buckets
|
||||||
|
for (const h of j.data?.pairHourDatas ?? []) {
|
||||||
|
const addr = h.pair.id.toLowerCase()
|
||||||
|
const pool = byAddr.get(addr)
|
||||||
|
if (!pool) continue
|
||||||
|
const entry = (stats[addr] ??= { vol24hUsd: 0, liqUsd: null, source: 'subgraph' })
|
||||||
|
const tracked = Number(h.hourlyVolumeUSD)
|
||||||
|
let usd: number | null = Number.isFinite(tracked) && tracked > 0 ? tracked : null
|
||||||
|
if (usd === null) {
|
||||||
|
// untracked bucket — approximate from the stable / WETH side
|
||||||
|
const v0 = Number(h.hourlyVolumeToken0)
|
||||||
|
const v1 = Number(h.hourlyVolumeToken1)
|
||||||
|
const t0 = pool.token0.toLowerCase()
|
||||||
|
const t1 = pool.token1.toLowerCase()
|
||||||
|
if (t0 === USDG && Number.isFinite(v0)) usd = v0
|
||||||
|
else if (t1 === USDG && Number.isFinite(v1)) usd = v1
|
||||||
|
else if (wethUsd !== null && t0 === WETH && Number.isFinite(v0)) usd = v0 * wethUsd
|
||||||
|
else if (wethUsd !== null && t1 === WETH && Number.isFinite(v1)) usd = v1 * wethUsd
|
||||||
|
}
|
||||||
|
if (usd !== null && entry.vol24hUsd !== null) entry.vol24hUsd += usd
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PoolStatsResult = {
|
||||||
|
byPool: Record<string, PoolStat> // key: lowercase pool address; uncovered pools absent
|
||||||
|
wethUsd: number | null // WETH/USD derived from dexscreener, reused as a price anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
/** merged 24h stats keyed by lowercase pool address; missing pools stay absent */
|
||||||
|
export async function fetchPoolStats(pools: Pool[]): Promise<PoolStatsResult> {
|
||||||
|
const clAddrs = pools.filter((p) => p.kind === 'cl').map((p) => p.address.toLowerCase())
|
||||||
|
const v2Pools = pools.filter((p): p is V2Pool => p.kind === 'v2')
|
||||||
|
const ds = await fetchDexscreener(clAddrs).catch(() => ({ stats: {}, wethUsd: null }))
|
||||||
|
const sg = await fetchV2Subgraph(v2Pools, ds.wethUsd).catch(() => ({}) as Record<string, PoolStat>)
|
||||||
|
return { byPool: { ...sg, ...ds.stats }, wethUsd: ds.wethUsd }
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Per-position metrics: what a position is WORTH and what it is EARNING
|
||||||
|
// RIGHT NOW — the two numbers an LP actually manages by. Pricing uses the
|
||||||
|
// pool's own price against the USDG/WETH/UP anchors (see clTokenUsd), so any
|
||||||
|
// pair quoted against an anchor prices with zero extra network calls.
|
||||||
|
//
|
||||||
|
// Earning-now follows the ve(3,3) ground rules (apr.ts):
|
||||||
|
// staked -> UP emissions, pro-rata ACTIVE staked liquidity, in-range only
|
||||||
|
// unstaked -> swap fees, pro-rata active liquidity, in-range only
|
||||||
|
// univ3 -> always the fees branch (no gauges)
|
||||||
|
import { ADDR } from '../config/addresses'
|
||||||
|
import { clTokenUsd } from './apr'
|
||||||
|
import { nowSec } from './format'
|
||||||
|
import type { PoolStat } from './poolstats'
|
||||||
|
import type { ClPosition, V2Position, V2Pool } from '../types'
|
||||||
|
|
||||||
|
export type Earning =
|
||||||
|
| { kind: 'emissions'; upPerDay: number; usdPerDay: number | null; aprPct: number | null; sharePct: number }
|
||||||
|
| { kind: 'emissions-idle'; reason: 'out-of-range' | 'ended' }
|
||||||
|
| { kind: 'fees'; aprPct: number; usdPerDay: number; sharePct: number }
|
||||||
|
| { kind: 'fees-unknown' } // in range but no 24h volume data
|
||||||
|
| { kind: 'out-of-range' }
|
||||||
|
| { kind: 'empty' }
|
||||||
|
|
||||||
|
export type ClMetrics = {
|
||||||
|
valueUsd: number | null
|
||||||
|
feesUsd: number | null
|
||||||
|
inRange: boolean
|
||||||
|
earning: Earning
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86_400
|
||||||
|
const YEAR_DAYS = 365
|
||||||
|
|
||||||
|
const emitting = (p: { rewardRate: bigint; periodFinish: bigint }) =>
|
||||||
|
p.rewardRate > 0n && p.periodFinish > BigInt(nowSec())
|
||||||
|
|
||||||
|
export function clPosMetrics(args: {
|
||||||
|
pos: ClPosition
|
||||||
|
/** live-aware held amounts + tick (cards pass the fast feed when they have one) */
|
||||||
|
amount0: bigint
|
||||||
|
amount1: bigint
|
||||||
|
tick: number
|
||||||
|
dec0: number
|
||||||
|
dec1: number
|
||||||
|
stat?: PoolStat
|
||||||
|
upUsd?: number
|
||||||
|
wethUsd?: number | null
|
||||||
|
}): ClMetrics {
|
||||||
|
const { pos, dec0, dec1 } = args
|
||||||
|
const pool = pos.pool
|
||||||
|
const px = clTokenUsd(pool, dec0, dec1, args.upUsd, args.wethUsd)
|
||||||
|
const h = (v: bigint, d: number) => Number(v) / 10 ** d
|
||||||
|
const valueUsd = px ? h(args.amount0, dec0) * px.p0 + h(args.amount1, dec1) * px.p1 : null
|
||||||
|
const feesUsd = px ? h(pos.fees0, dec0) * px.p0 + h(pos.fees1, dec1) * px.p1 : null
|
||||||
|
const inRange = args.tick >= pos.tickLower && args.tick < pos.tickUpper
|
||||||
|
|
||||||
|
let earning: Earning
|
||||||
|
if (pos.liquidity === 0n) {
|
||||||
|
earning = { kind: 'empty' }
|
||||||
|
} else if (pos.staked) {
|
||||||
|
if (!inRange) earning = { kind: 'emissions-idle', reason: 'out-of-range' }
|
||||||
|
else if (!emitting(pool)) earning = { kind: 'emissions-idle', reason: 'ended' }
|
||||||
|
else {
|
||||||
|
const denom = Number(pool.stakedLiquidity)
|
||||||
|
const share = denom > 0 ? Number(pos.liquidity) / denom : 0
|
||||||
|
const upPerDay = (Number(pool.rewardRate) / 1e18) * DAY * share
|
||||||
|
const usdPerDay = args.upUsd ? upPerDay * args.upUsd : null
|
||||||
|
const aprPct =
|
||||||
|
usdPerDay !== null && valueUsd !== null && valueUsd > 0 ? ((usdPerDay * YEAR_DAYS) / valueUsd) * 100 : null
|
||||||
|
earning = { kind: 'emissions', upPerDay, usdPerDay, aprPct, sharePct: share * 100 }
|
||||||
|
}
|
||||||
|
} else if (!inRange) {
|
||||||
|
earning = { kind: 'out-of-range' }
|
||||||
|
} else if (args.stat?.vol24hUsd == null || valueUsd === null || valueUsd <= 0) {
|
||||||
|
earning = { kind: 'fees-unknown' }
|
||||||
|
} else {
|
||||||
|
const denom = Number(pool.liquidity)
|
||||||
|
const share = denom > 0 ? Number(pos.liquidity) / denom : 0
|
||||||
|
const keep = 1 - pool.unstakedFeePpm / 1e6
|
||||||
|
const usdPerDay = args.stat.vol24hUsd * (pool.feePpm / 1e6) * keep * share
|
||||||
|
earning = { kind: 'fees', aprPct: ((usdPerDay * YEAR_DAYS) / valueUsd) * 100, usdPerDay, sharePct: share * 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valueUsd, feesUsd, inRange, earning }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** USD prices of a v2 pool's tokens: anchor + reserve ratio, TVL/2 backstop */
|
||||||
|
export function v2TokenUsd(
|
||||||
|
pool: V2Pool,
|
||||||
|
dec0: number,
|
||||||
|
dec1: number,
|
||||||
|
upUsd?: number,
|
||||||
|
wethUsd?: number | null,
|
||||||
|
statLiqUsd?: number | null,
|
||||||
|
): { p0: number; p1: number } | null {
|
||||||
|
const r0h = Number(pool.reserve0) / 10 ** dec0
|
||||||
|
const r1h = Number(pool.reserve1) / 10 ** dec1
|
||||||
|
if (!(r0h > 0) || !(r1h > 0)) return null
|
||||||
|
const anchors: Record<string, number | undefined> = {
|
||||||
|
[ADDR.USDG.toLowerCase()]: 1,
|
||||||
|
[ADDR.WETH.toLowerCase()]: wethUsd ?? undefined,
|
||||||
|
[ADDR.UP.toLowerCase()]: upUsd,
|
||||||
|
}
|
||||||
|
const a0 = anchors[pool.token0.toLowerCase()]
|
||||||
|
const a1 = anchors[pool.token1.toLowerCase()]
|
||||||
|
// value balance: r0·p0 ≈ r1·p1 (true for volatile pairs; good enough for stables)
|
||||||
|
if (a0 !== undefined && a0 > 0) return { p0: a0, p1: (a0 * r0h) / r1h }
|
||||||
|
if (a1 !== undefined && a1 > 0) return { p0: (a1 * r1h) / r0h, p1: a1 }
|
||||||
|
if (statLiqUsd != null && statLiqUsd > 0) return { p0: statLiqUsd / 2 / r0h, p1: statLiqUsd / 2 / r1h }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2Metrics = {
|
||||||
|
valueUsd: number | null // wallet + staked underlying
|
||||||
|
feesUsd: number | null // claimable fees
|
||||||
|
staked: Earning | null // null when nothing staked
|
||||||
|
wallet: Earning | null // null when no wallet LP
|
||||||
|
}
|
||||||
|
|
||||||
|
export function v2PosMetrics(args: {
|
||||||
|
pos: V2Position
|
||||||
|
dec0: number
|
||||||
|
dec1: number
|
||||||
|
stat?: PoolStat
|
||||||
|
upUsd?: number
|
||||||
|
wethUsd?: number | null
|
||||||
|
}): V2Metrics {
|
||||||
|
const { pos, dec0, dec1 } = args
|
||||||
|
const pool = pos.pool
|
||||||
|
const px = v2TokenUsd(pool, dec0, dec1, args.upUsd, args.wethUsd, args.stat?.liqUsd)
|
||||||
|
const h = (v: bigint, d: number) => Number(v) / 10 ** d
|
||||||
|
const valueUsd = px ? h(pos.amount0, dec0) * px.p0 + h(pos.amount1, dec1) * px.p1 : null
|
||||||
|
const feesUsd = px ? h(pos.claimable0, dec0) * px.p0 + h(pos.claimable1, dec1) * px.p1 : null
|
||||||
|
|
||||||
|
const lp = pos.walletLp + pos.stakedLp
|
||||||
|
const stakedValue = valueUsd !== null && lp > 0n ? (valueUsd * Number(pos.stakedLp)) / Number(lp) : null
|
||||||
|
const walletValue = valueUsd !== null && lp > 0n ? (valueUsd * Number(pos.walletLp)) / Number(lp) : null
|
||||||
|
|
||||||
|
let staked: Earning | null = null
|
||||||
|
if (pos.stakedLp > 0n) {
|
||||||
|
if (!emitting(pool)) staked = { kind: 'emissions-idle', reason: 'ended' }
|
||||||
|
else {
|
||||||
|
const denom = Number(pool.gaugeTotalSupply)
|
||||||
|
const share = denom > 0 ? Number(pos.stakedLp) / denom : 0
|
||||||
|
const upPerDay = (Number(pool.rewardRate) / 1e18) * DAY * share
|
||||||
|
const usdPerDay = args.upUsd ? upPerDay * args.upUsd : null
|
||||||
|
const aprPct =
|
||||||
|
usdPerDay !== null && stakedValue !== null && stakedValue > 0
|
||||||
|
? ((usdPerDay * YEAR_DAYS) / stakedValue) * 100
|
||||||
|
: null
|
||||||
|
staked = { kind: 'emissions', upPerDay, usdPerDay, aprPct, sharePct: share * 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wallet: Earning | null = null
|
||||||
|
if (pos.walletLp > 0n) {
|
||||||
|
if (args.stat?.vol24hUsd == null || args.stat.liqUsd == null || args.stat.liqUsd <= 0) {
|
||||||
|
wallet = { kind: 'fees-unknown' }
|
||||||
|
} else {
|
||||||
|
const aprPct = ((args.stat.vol24hUsd * (pool.feeBps / 10_000) * YEAR_DAYS) / args.stat.liqUsd) * 100
|
||||||
|
const usdPerDay = walletValue !== null ? (walletValue * aprPct) / 100 / YEAR_DAYS : 0
|
||||||
|
wallet = { kind: 'fees', aprPct, usdPerDay, sharePct: lp > 0n ? (Number(pos.walletLp) / Number(pool.totalSupply)) * 100 : 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valueUsd, feesUsd, staked, wallet }
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// user-selectable RPC endpoint, persisted per-browser (localStorage).
|
||||||
|
// Read once at startup by config/wagmi.ts (highest-priority transport);
|
||||||
|
// changes apply via page reload.
|
||||||
|
|
||||||
|
const KEY = 'up33.rpcUrl.v1'
|
||||||
|
|
||||||
|
export function customRpc(): string {
|
||||||
|
try {
|
||||||
|
return (localStorage.getItem(KEY) ?? '').trim()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCustomRpc(url: string) {
|
||||||
|
try {
|
||||||
|
const v = url.trim()
|
||||||
|
if (v) localStorage.setItem(KEY, v)
|
||||||
|
else localStorage.removeItem(KEY)
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidRpcUrl(u: string): boolean {
|
||||||
|
try {
|
||||||
|
const p = new URL(u)
|
||||||
|
return p.protocol === 'https:' || p.protocol === 'http:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cheap sanity check before saving: endpoint answers eth_chainId with the right chain */
|
||||||
|
export async function probeRpc(
|
||||||
|
url: string,
|
||||||
|
expectChainId: number,
|
||||||
|
timeoutMs = 6_000,
|
||||||
|
): Promise<{ ok: true } | { ok: false; err: string }> {
|
||||||
|
try {
|
||||||
|
const ctl = new AbortController()
|
||||||
|
const t = setTimeout(() => ctl.abort(), timeoutMs)
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] }),
|
||||||
|
signal: ctl.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(t)
|
||||||
|
const j = await r.json().catch(() => null)
|
||||||
|
const id = typeof j?.result === 'string' ? parseInt(j.result, 16) : NaN
|
||||||
|
if (!r.ok || !Number.isFinite(id)) return { ok: false, err: `no eth_chainId answer (http ${r.status})` }
|
||||||
|
if (id !== expectChainId) return { ok: false, err: `wrong chain: got ${id}, need ${expectChainId}` }
|
||||||
|
return { ok: true }
|
||||||
|
} catch {
|
||||||
|
return { ok: false, err: 'unreachable (network/CORS)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// One-shot handoff "swap this next" — set after a claim confirms, consumed by
|
||||||
|
// the SWAP tab when it can resolve both tokens.
|
||||||
|
import type { Address } from 'viem'
|
||||||
|
|
||||||
|
export type SwapIntent = { tokenIn: Address; tokenOut: Address; amount: bigint }
|
||||||
|
|
||||||
|
let intent: SwapIntent | null = null
|
||||||
|
|
||||||
|
export function setSwapIntent(i: SwapIntent) {
|
||||||
|
intent = i
|
||||||
|
}
|
||||||
|
export function peekSwapIntent(): SwapIntent | null {
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
export function takeSwapIntent(): SwapIntent | null {
|
||||||
|
const i = intent
|
||||||
|
intent = null
|
||||||
|
return i
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Theme registry. The <html data-theme> attribute selects a variable block in
|
||||||
|
// styles.css; this module carries the few values JS needs (RainbowKit accent)
|
||||||
|
// and the persistence/sync plumbing. index.html applies the saved theme before
|
||||||
|
// first paint (plus a ?theme= view-only override for screenshots/sharing).
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export const THEMES = {
|
||||||
|
mono: { label: 'MONO', acc: '#ffffff', accFg: '#000000' },
|
||||||
|
phosphor: { label: 'PHOSPHOR', acc: '#52ff2e', accFg: '#061006' },
|
||||||
|
amber: { label: 'AMBER', acc: '#ffb000', accFg: '#140d02' },
|
||||||
|
ice: { label: 'ICE', acc: '#4dc9ff', accFg: '#041019' },
|
||||||
|
violet: { label: 'VIOLET', acc: '#b18cff', accFg: '#120a20' },
|
||||||
|
} as const
|
||||||
|
export type ThemeId = keyof typeof THEMES
|
||||||
|
|
||||||
|
const KEY = 'up33.theme.v1'
|
||||||
|
const DEFAULT: ThemeId = 'mono' // user's pick: black ground, white type, green LP ranges
|
||||||
|
|
||||||
|
export function currentTheme(): ThemeId {
|
||||||
|
const t = document.documentElement.dataset.theme as ThemeId | undefined
|
||||||
|
if (t && t in THEMES) return t
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem(KEY) as ThemeId | null
|
||||||
|
if (s && s in THEMES) return s
|
||||||
|
} catch {
|
||||||
|
/* storage blocked */
|
||||||
|
}
|
||||||
|
return DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(t: ThemeId): void {
|
||||||
|
document.documentElement.dataset.theme = t
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, t)
|
||||||
|
} catch {
|
||||||
|
/* storage blocked — theme still applies for this tab */
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('up33:theme', { detail: t }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** live theme id — re-renders subscribers when the footer control switches */
|
||||||
|
export function useTheme(): ThemeId {
|
||||||
|
const [t, setT] = useState<ThemeId>(currentTheme)
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: Event) => setT((e as CustomEvent).detail as ThemeId)
|
||||||
|
window.addEventListener('up33:theme', h)
|
||||||
|
return () => window.removeEventListener('up33:theme', h)
|
||||||
|
}, [])
|
||||||
|
return t
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
import { readContract, waitForTransactionReceipt } from 'wagmi/actions'
|
||||||
|
import type { Address, Hex, TransactionReceipt } from 'viem'
|
||||||
|
import { erc20Abi, parseAbi, parseEventLogs } from 'viem'
|
||||||
|
import { writeContract } from 'wagmi/actions'
|
||||||
|
import { wagmiConfig } from '../config/wagmi'
|
||||||
|
import { queryClient } from '../config/query'
|
||||||
|
import { ADDR, CHAIN_ID } from '../config/addresses'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import { fmtAmount } from './format'
|
||||||
|
import { NATIVE } from './kyber'
|
||||||
|
import { setSwapIntent } from './swapIntent'
|
||||||
|
import { txlog } from './txlog'
|
||||||
|
|
||||||
|
// Slipstream periphery's abbreviated revert reasons, translated for humans
|
||||||
|
// (hints resolve lazily so they follow the active language at error time)
|
||||||
|
const REVERT_HINTS: [RegExp, () => string][] = [
|
||||||
|
[/Return amount is not enough/i, () => t('tx.hintKyberMinOut')],
|
||||||
|
[/reason:\s*PS\b/, () => t('tx.hintPS')],
|
||||||
|
[/INSUFFICIENT_[AB]_AMOUNT/, () => t('tx.hintV2Ratio')],
|
||||||
|
[/reason:\s*STF\b/, () => t('tx.hintSTF')],
|
||||||
|
[/reason:\s*NP\b/, () => t('tx.hintNP')],
|
||||||
|
[/Transaction too old/i, () => t('tx.hintDeadline')],
|
||||||
|
]
|
||||||
|
|
||||||
|
function shortErr(e: unknown): string {
|
||||||
|
const anyE = e as any
|
||||||
|
const m: string = anyE?.shortMessage ?? anyE?.message ?? String(e)
|
||||||
|
const first = m.split('\n')[0]
|
||||||
|
const base = first.length > 110 ? first.slice(0, 110) + '…' : first
|
||||||
|
for (const [re, hint] of REVERT_HINTS) if (re.test(first)) return `${base} → ${hint()}`
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// slot0's leading (sqrtPriceX96, tick) is shared by Slipstream (6-word) and
|
||||||
|
// Uniswap v3 (7-word, extra feeProtocol) pools; decoding only the prefix keeps
|
||||||
|
// this one helper valid for both — viem ignores the trailing words.
|
||||||
|
const slot0PrefixAbi = parseAbi([
|
||||||
|
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick)',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** live pool price for slippage math — never trust the cached pools query for mins */
|
||||||
|
export async function fetchSqrtPriceX96(pool: Address): Promise<bigint> {
|
||||||
|
const s0 = await readContract(wagmiConfig, {
|
||||||
|
abi: slot0PrefixAbi,
|
||||||
|
address: pool,
|
||||||
|
functionName: 'slot0',
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
})
|
||||||
|
return s0[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAll() {
|
||||||
|
void queryClient.invalidateQueries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one transaction step: wallet prompt -> pending -> receipt.
|
||||||
|
* Returns the tx hash on success, null on rejection/revert (callers should stop a
|
||||||
|
* multi-step flow on null). opts.onSuccess sees the confirmed receipt.
|
||||||
|
*/
|
||||||
|
export async function step(
|
||||||
|
label: string,
|
||||||
|
send: () => Promise<Hex>,
|
||||||
|
opts?: { onSuccess?: (rcpt: TransactionReceipt) => void },
|
||||||
|
): Promise<Hex | null> {
|
||||||
|
const id = txlog.push('pending', t('tx.confirm', { label }))
|
||||||
|
try {
|
||||||
|
const hash = await send()
|
||||||
|
txlog.update(id, { text: t('tx.pending', { label }), hash })
|
||||||
|
const rcpt = await waitForTransactionReceipt(wagmiConfig, { hash, chainId: CHAIN_ID })
|
||||||
|
if (rcpt.status !== 'success') {
|
||||||
|
txlog.update(id, { kind: 'err', text: t('tx.reverted', { label }), hash })
|
||||||
|
invalidateAll()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
txlog.update(id, { kind: 'ok', text: t('tx.ok', { label, n: rcpt.blockNumber.toString() }), hash })
|
||||||
|
invalidateAll()
|
||||||
|
try {
|
||||||
|
opts?.onSuccess?.(rcpt)
|
||||||
|
} catch {
|
||||||
|
/* follow-up is best-effort */
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
} catch (e) {
|
||||||
|
txlog.update(id, { kind: 'err', text: `${label} — ${shortErr(e)}` })
|
||||||
|
invalidateAll()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** total of `token` delivered to `to` in a receipt's Transfer logs — the
|
||||||
|
* ground truth for "how much did I actually receive" after a swap/claim */
|
||||||
|
export function receivedOf(rcpt: TransactionReceipt, token: Address, to: Address): bigint {
|
||||||
|
const transfers = parseEventLogs({ abi: erc20Abi, logs: rcpt.logs, eventName: 'Transfer' })
|
||||||
|
let total = 0n
|
||||||
|
for (const t of transfers) {
|
||||||
|
if (t.address.toLowerCase() === token.toLowerCase() && t.args.to?.toLowerCase() === to.toLowerCase()) {
|
||||||
|
total += t.args.value ?? 0n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-claim follow-up: read how much UP actually landed in the wallet from the
|
||||||
|
* receipt's Transfer logs and offer a one-click "swap it to ETH" that jumps to
|
||||||
|
* the SWAP tab prefilled with the exact claimed amount.
|
||||||
|
*/
|
||||||
|
export function offerSwapClaimedUp(user: Address) {
|
||||||
|
return (rcpt: TransactionReceipt) => {
|
||||||
|
const total = receivedOf(rcpt, ADDR.UP, user)
|
||||||
|
if (total === 0n) return
|
||||||
|
txlog.push('info', t('tx.received', { amt: fmtAmount(total, 18) }), rcpt.transactionHash, {
|
||||||
|
label: t('tx.swapToEth'),
|
||||||
|
onClick: () => {
|
||||||
|
setSwapIntent({ tokenIn: ADDR.UP, tokenOut: NATIVE, amount: total })
|
||||||
|
location.hash = 'swap'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve `spender` for exactly `amount` if current allowance is lower. */
|
||||||
|
export async function ensureAllowance(
|
||||||
|
token: Address,
|
||||||
|
owner: Address,
|
||||||
|
spender: Address,
|
||||||
|
amount: bigint,
|
||||||
|
symbol: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const current = await readContract(wagmiConfig, {
|
||||||
|
abi: erc20Abi,
|
||||||
|
address: token,
|
||||||
|
functionName: 'allowance',
|
||||||
|
args: [owner, spender],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
})
|
||||||
|
if (current >= amount) return true
|
||||||
|
const h = await step(t('tx.approve', { sym: symbol }), () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: erc20Abi,
|
||||||
|
address: token,
|
||||||
|
functionName: 'approve',
|
||||||
|
args: [spender, amount],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return h !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deadline(secondsFromNow = 1200): bigint {
|
||||||
|
return BigInt(Math.floor(Date.now() / 1000) + secondsFromNow)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Tiny external store for the terminal activity log (no deps).
|
||||||
|
|
||||||
|
export type LogKind = 'info' | 'pending' | 'ok' | 'err'
|
||||||
|
export type LogAction = { label: string; onClick: () => void }
|
||||||
|
export type LogLine = {
|
||||||
|
id: number
|
||||||
|
ts: number
|
||||||
|
kind: LogKind
|
||||||
|
text: string
|
||||||
|
hash?: string
|
||||||
|
action?: LogAction
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines: LogLine[] = []
|
||||||
|
let nextId = 1
|
||||||
|
const subs = new Set<() => void>()
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
subs.forEach((f) => f())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const txlog = {
|
||||||
|
push(kind: LogKind, text: string, hash?: string, action?: LogAction): number {
|
||||||
|
const id = nextId++
|
||||||
|
lines = [...lines, { id, ts: Date.now(), kind, text, hash, action }].slice(-200)
|
||||||
|
emit()
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
update(id: number, patch: Partial<Omit<LogLine, 'id'>>) {
|
||||||
|
lines = lines.map((l) => (l.id === id ? { ...l, ...patch, ts: Date.now() } : l))
|
||||||
|
emit()
|
||||||
|
},
|
||||||
|
get(): LogLine[] {
|
||||||
|
return lines
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
lines = []
|
||||||
|
emit()
|
||||||
|
},
|
||||||
|
subscribe(f: () => void): () => void {
|
||||||
|
subs.add(f)
|
||||||
|
return () => subs.delete(f)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Uniswap v3 pool discovery for the POOLS tab.
|
||||||
|
//
|
||||||
|
// Why not RPC enumeration: the factory has thousands of PoolCreated events
|
||||||
|
// (mostly dust-TVL 1%-tier meme pools) and both public RPC (query timeout) and
|
||||||
|
// Alchemy (10k-block getLogs cap ≈ 1100+ requests) make in-browser scans
|
||||||
|
// unrealistic. There is also no official Uniswap subgraph for Robinhood Chain
|
||||||
|
// (official Graph deployments are mainnet-only as of 2026-07).
|
||||||
|
//
|
||||||
|
// So discovery is token-centric via DexScreener (already same-origin proxied
|
||||||
|
// in server mode), and every candidate is VERIFIED on-chain before display:
|
||||||
|
// the pool's own token0/token1/fee must round-trip through factory.getPool to
|
||||||
|
// the same address — an API can suggest pools, it can never substitute one.
|
||||||
|
import { getAddress, zeroAddress, type Address, type PublicClient } from 'viem'
|
||||||
|
import { erc20Abi, uniV3FactoryAbi, uniV3PoolAbi } from '../abi'
|
||||||
|
import { ADDR, UNI } from '../config/addresses'
|
||||||
|
import { ENV } from '../config/env'
|
||||||
|
import { loadTokenCache, saveTokenCache } from '../hooks/usePools'
|
||||||
|
import type { PoolStat } from './poolstats'
|
||||||
|
import type { ClPool, TokenInfo } from '../types'
|
||||||
|
|
||||||
|
const DS = ENV.proxied ? '/dexscreener' : 'https://api.dexscreener.com'
|
||||||
|
const CAP = 30 // top pools by TVL per query — keeps the verify multicall small
|
||||||
|
|
||||||
|
type DsPair = {
|
||||||
|
chainId?: string
|
||||||
|
dexId?: string
|
||||||
|
labels?: string[]
|
||||||
|
pairAddress?: string
|
||||||
|
volume?: { h24?: number }
|
||||||
|
liquidity?: { usd?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UniBrowse = {
|
||||||
|
pools: ClPool[]
|
||||||
|
tokens: Record<string, TokenInfo>
|
||||||
|
stats: Record<string, PoolStat> // lowercase pool address
|
||||||
|
candidates: number // dexscreener v3 matches before cap + on-chain verify
|
||||||
|
dropped: number // candidates that failed factory.getPool verification
|
||||||
|
}
|
||||||
|
|
||||||
|
function v3PairsOf(json: unknown): DsPair[] {
|
||||||
|
const arr = Array.isArray(json) ? json : ((json as { pairs?: DsPair[] })?.pairs ?? [])
|
||||||
|
return arr.filter(
|
||||||
|
(p) => p?.chainId === 'robinhood' && p?.dexId === 'uniswap' && (p?.labels ?? []).includes('v3'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dsJson(path: string): Promise<unknown> {
|
||||||
|
const r = await fetch(DS + path)
|
||||||
|
if (!r.ok) throw new Error(`dexscreener ${r.status}`)
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dexscreener candidates for a query: token address, pool address, or symbol text */
|
||||||
|
async function candidatesFor(query: string): Promise<DsPair[]> {
|
||||||
|
const q = query.trim()
|
||||||
|
if (/^0x[0-9a-fA-F]{40}$/.test(q)) {
|
||||||
|
// token address first (the common case), then pool-address lookup
|
||||||
|
const byToken = v3PairsOf(await dsJson(`/token-pairs/v1/robinhood/${q}`).catch(() => null))
|
||||||
|
if (byToken.length) return byToken
|
||||||
|
return v3PairsOf(await dsJson(`/latest/dex/pairs/robinhood/${q}`).catch(() => null))
|
||||||
|
}
|
||||||
|
return v3PairsOf(await dsJson(`/latest/dex/search?q=${encodeURIComponent(q)}`).catch(() => null))
|
||||||
|
}
|
||||||
|
|
||||||
|
type McRes = { status: 'success' | 'failure'; result?: unknown }
|
||||||
|
const ok = <T,>(r: McRes | undefined): T | undefined =>
|
||||||
|
r && r.status === 'success' ? (r.result as T) : undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover + on-chain-verify Uniswap v3 pools. `query` empty = WETH (hub token).
|
||||||
|
* Returns ready-to-render ClPool objects (protocol 'univ3', no gauge fields).
|
||||||
|
*/
|
||||||
|
export async function fetchUniBrowse(pc: PublicClient, query: string): Promise<UniBrowse> {
|
||||||
|
const raw = await candidatesFor(query || ADDR.WETH)
|
||||||
|
|
||||||
|
// dedupe, rank by TVL, cap
|
||||||
|
const seen = new Map<string, DsPair>()
|
||||||
|
for (const p of raw) {
|
||||||
|
const a = p.pairAddress?.toLowerCase()
|
||||||
|
if (a && !seen.has(a)) seen.set(a, p)
|
||||||
|
}
|
||||||
|
const ranked = [...seen.values()].sort(
|
||||||
|
(a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0),
|
||||||
|
)
|
||||||
|
const picks = ranked.slice(0, CAP)
|
||||||
|
|
||||||
|
const stats: Record<string, PoolStat> = {}
|
||||||
|
for (const p of picks) {
|
||||||
|
const vol = Number(p.volume?.h24)
|
||||||
|
const liq = Number(p.liquidity?.usd)
|
||||||
|
stats[p.pairAddress!.toLowerCase()] = {
|
||||||
|
vol24hUsd: Number.isFinite(vol) ? vol : null,
|
||||||
|
liqUsd: Number.isFinite(liq) ? liq : null,
|
||||||
|
source: 'dexscreener',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrate pool state from the pool contracts themselves
|
||||||
|
const addrs = picks.map((p) => getAddress(p.pairAddress!))
|
||||||
|
const det = (await pc.multicall({
|
||||||
|
contracts: addrs.flatMap((a) => [
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'token0' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'token1' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'fee' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'tickSpacing' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'slot0' },
|
||||||
|
{ abi: uniV3PoolAbi, address: a, functionName: 'liquidity' },
|
||||||
|
]) as never,
|
||||||
|
})) as McRes[]
|
||||||
|
|
||||||
|
type Hyd = { addr: Address; token0: Address; token1: Address; fee: number; ts: number; s0: readonly [bigint, number]; liq: bigint }
|
||||||
|
const hyd: Hyd[] = []
|
||||||
|
addrs.forEach((a, i) => {
|
||||||
|
const token0 = ok<Address>(det[i * 6])
|
||||||
|
const token1 = ok<Address>(det[i * 6 + 1])
|
||||||
|
const fee = ok<number>(det[i * 6 + 2])
|
||||||
|
const ts = ok<number>(det[i * 6 + 3])
|
||||||
|
const s0 = ok<readonly [bigint, number]>(det[i * 6 + 4])
|
||||||
|
const liq = ok<bigint>(det[i * 6 + 5])
|
||||||
|
if (!token0 || !token1 || fee === undefined || ts === undefined || !s0) return
|
||||||
|
hyd.push({ addr: a, token0, token1, fee, ts, s0, liq: liq ?? 0n })
|
||||||
|
})
|
||||||
|
|
||||||
|
// authenticity gate: the OFFICIAL factory must map (token0, token1, fee) back
|
||||||
|
// to this exact address, else it's a fork/spoof pool and gets dropped
|
||||||
|
const gp = (await pc.multicall({
|
||||||
|
contracts: hyd.map((h) => ({
|
||||||
|
abi: uniV3FactoryAbi,
|
||||||
|
address: UNI.V3_FACTORY,
|
||||||
|
functionName: 'getPool',
|
||||||
|
args: [h.token0, h.token1, h.fee],
|
||||||
|
})) as never,
|
||||||
|
})) as McRes[]
|
||||||
|
const verified = hyd.filter((h, i) => {
|
||||||
|
const mapped = ok<Address>(gp[i])
|
||||||
|
return !!mapped && mapped !== zeroAddress && mapped.toLowerCase() === h.addr.toLowerCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
// token metadata (shared localStorage cache with the UP33 pool scan)
|
||||||
|
const cache = loadTokenCache()
|
||||||
|
const tokens: Record<string, TokenInfo> = {}
|
||||||
|
const missing: Address[] = []
|
||||||
|
for (const h of verified) {
|
||||||
|
for (const t of [h.token0, h.token1]) {
|
||||||
|
const k = t.toLowerCase()
|
||||||
|
if (tokens[k]) continue
|
||||||
|
if (cache[k]) tokens[k] = cache[k]
|
||||||
|
else if (!missing.some((m) => m.toLowerCase() === k)) missing.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length) {
|
||||||
|
const meta = (await pc.multicall({
|
||||||
|
contracts: missing.flatMap((t) => [
|
||||||
|
{ abi: erc20Abi, address: t, functionName: 'symbol' },
|
||||||
|
{ abi: erc20Abi, address: t, functionName: 'decimals' },
|
||||||
|
]) as never,
|
||||||
|
})) as McRes[]
|
||||||
|
missing.forEach((t, j) => {
|
||||||
|
const info: TokenInfo = {
|
||||||
|
address: t,
|
||||||
|
symbol: ok<string>(meta[j * 2]) ?? t.slice(0, 6) + '…',
|
||||||
|
decimals: ok<number>(meta[j * 2 + 1]) ?? 18,
|
||||||
|
}
|
||||||
|
tokens[t.toLowerCase()] = info
|
||||||
|
cache[t.toLowerCase()] = info
|
||||||
|
})
|
||||||
|
saveTokenCache(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pools: ClPool[] = verified.map((h) => ({
|
||||||
|
kind: 'cl',
|
||||||
|
protocol: 'univ3',
|
||||||
|
address: h.addr,
|
||||||
|
token0: h.token0,
|
||||||
|
token1: h.token1,
|
||||||
|
tickSpacing: h.ts,
|
||||||
|
feePpm: h.fee, // univ3 fee unit == ppm
|
||||||
|
unstakedFeePpm: 0, // no ve(3,3) levy — LPs keep 100% of fees
|
||||||
|
sqrtPriceX96: h.s0[0],
|
||||||
|
tick: h.s0[1],
|
||||||
|
liquidity: h.liq,
|
||||||
|
stakedLiquidity: 0n,
|
||||||
|
gauge: null,
|
||||||
|
gaugeAlive: false,
|
||||||
|
weight: 0n,
|
||||||
|
rewardRate: 0n,
|
||||||
|
periodFinish: 0n,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { pools, tokens, stats, candidates: ranked.length, dropped: hyd.length - verified.length }
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Typed client for the pool-indexer API (indexer/, same-origin /api).
|
||||||
|
//
|
||||||
|
// The indexer owns the full Uniswap v2+v3 catalog — built exclusively from the
|
||||||
|
// official factories (PoolCreated events / allPairs enumeration), so every
|
||||||
|
// address is authentic by construction — plus on-chain state, chain-derived
|
||||||
|
// TVL and GeckoTerminal 24h stats. This module maps its JSON (bigints travel
|
||||||
|
// as strings) onto the app's Pool/TokenInfo/PoolStat shapes.
|
||||||
|
//
|
||||||
|
// Returns null when the API is unreachable or still warming up (ready:false):
|
||||||
|
// the caller then falls back to client-side dexscreener discovery (uniBrowse).
|
||||||
|
import { getAddress } from 'viem'
|
||||||
|
import type { PoolStat } from './poolstats'
|
||||||
|
import type { Pool, TokenInfo } from '../types'
|
||||||
|
|
||||||
|
type ApiPool = {
|
||||||
|
proto: 'univ2' | 'univ3'
|
||||||
|
address: string
|
||||||
|
token0: string
|
||||||
|
token1: string
|
||||||
|
feePpm: number
|
||||||
|
tickSpacing: number | null
|
||||||
|
sqrtPriceX96: string | null
|
||||||
|
tick: number | null
|
||||||
|
liquidity: string | null
|
||||||
|
reserve0: string
|
||||||
|
reserve1: string
|
||||||
|
totalSupply: string | null
|
||||||
|
tvlUsd: number | null
|
||||||
|
vol24hUsd: number | null
|
||||||
|
txns24h: number | null
|
||||||
|
gtLiqUsd: number | null
|
||||||
|
statsSource: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiResponse = {
|
||||||
|
ready: boolean
|
||||||
|
totals: Record<string, number>
|
||||||
|
count: number
|
||||||
|
pools: ApiPool[]
|
||||||
|
tokens: Record<string, { address: string; symbol: string; decimals: number; priceUsd: number | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UniIndexData = {
|
||||||
|
pools: Pool[]
|
||||||
|
tokens: Record<string, TokenInfo>
|
||||||
|
stats: Record<string, PoolStat>
|
||||||
|
total: number // server-side matches before the page limit
|
||||||
|
indexed: number // whole catalog size (univ2 + univ3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroBase = { gauge: null, gaugeAlive: false, weight: 0n, rewardRate: 0n, periodFinish: 0n } as const
|
||||||
|
|
||||||
|
export async function fetchUniIndex(
|
||||||
|
query: string,
|
||||||
|
minTvl: number,
|
||||||
|
proto?: 'univ2' | 'univ3',
|
||||||
|
limit = 120,
|
||||||
|
): Promise<UniIndexData | null> {
|
||||||
|
let j: ApiResponse
|
||||||
|
try {
|
||||||
|
const u = new URL('/api/pools', location.origin)
|
||||||
|
const q = query.trim()
|
||||||
|
if (q) u.searchParams.set('q', q)
|
||||||
|
if (minTvl > 0) u.searchParams.set('min_tvl', String(minTvl))
|
||||||
|
if (proto) u.searchParams.set('proto', proto)
|
||||||
|
u.searchParams.set('limit', String(limit))
|
||||||
|
const r = await fetch(u)
|
||||||
|
if (!r.ok) return null
|
||||||
|
j = (await r.json()) as ApiResponse
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!j?.ready || !Array.isArray(j.pools)) return null
|
||||||
|
|
||||||
|
const pools: Pool[] = []
|
||||||
|
const stats: Record<string, PoolStat> = {}
|
||||||
|
for (const p of j.pools) {
|
||||||
|
const base = {
|
||||||
|
address: getAddress(p.address),
|
||||||
|
token0: getAddress(p.token0),
|
||||||
|
token1: getAddress(p.token1),
|
||||||
|
...zeroBase,
|
||||||
|
}
|
||||||
|
if (p.proto === 'univ3') {
|
||||||
|
if (!p.sqrtPriceX96) continue // state not swept yet (brand-new pool) — next poll has it
|
||||||
|
pools.push({
|
||||||
|
...base,
|
||||||
|
kind: 'cl',
|
||||||
|
protocol: 'univ3',
|
||||||
|
tickSpacing: p.tickSpacing ?? 0,
|
||||||
|
feePpm: p.feePpm, // univ3 fee unit == ppm
|
||||||
|
unstakedFeePpm: 0, // no ve(3,3) levy
|
||||||
|
sqrtPriceX96: BigInt(p.sqrtPriceX96),
|
||||||
|
tick: p.tick ?? 0,
|
||||||
|
liquidity: BigInt(p.liquidity ?? '0'),
|
||||||
|
stakedLiquidity: 0n,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pools.push({
|
||||||
|
...base,
|
||||||
|
kind: 'v2',
|
||||||
|
protocol: 'univ2',
|
||||||
|
stable: false,
|
||||||
|
reserve0: BigInt(p.reserve0),
|
||||||
|
reserve1: BigInt(p.reserve1),
|
||||||
|
totalSupply: BigInt(p.totalSupply ?? '0'),
|
||||||
|
gaugeTotalSupply: 0n,
|
||||||
|
feeBps: Math.round(p.feePpm / 100), // 3000 ppm -> 30 bps (0.30%)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stats[p.address.toLowerCase()] = {
|
||||||
|
vol24hUsd: p.vol24hUsd,
|
||||||
|
liqUsd: p.tvlUsd ?? p.gtLiqUsd, // chain-derived TVL first, GT reserve as backstop
|
||||||
|
source: p.statsSource === 'geckoterminal' ? 'geckoterminal' : 'chain',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: Record<string, TokenInfo> = {}
|
||||||
|
for (const [k, t] of Object.entries(j.tokens ?? {}))
|
||||||
|
tokens[k] = { address: getAddress(t.address), symbol: t.symbol, decimals: t.decimals }
|
||||||
|
|
||||||
|
const indexed = Object.values(j.totals ?? {}).reduce((a, b) => a + b, 0)
|
||||||
|
return { pools, tokens, stats, total: j.count, indexed }
|
||||||
|
}
|
||||||
+524
@@ -0,0 +1,524 @@
|
|||||||
|
// ZAP: single-token add-liquidity. The user funds a position with ONE token
|
||||||
|
// (or native ETH); we solve how much of it to swap into the counter-token so
|
||||||
|
// the two piles match the deposit ratio the target needs, swap via the gated
|
||||||
|
// Kyber path, then deposit — one flow, N wallet-signed txs, halting on any
|
||||||
|
// failure (a halt never strands value: every intermediate asset is a normal
|
||||||
|
// wallet balance).
|
||||||
|
//
|
||||||
|
// Split solve: the needed ratio ρ (raw counter-units per raw kept-unit) comes
|
||||||
|
// from CL band math (or v2 reserves); with quote rate q̂ (counter per swapped
|
||||||
|
// unit) the swap size is s = A·ρ/(q̂+ρ). q̂ isn't known until we quote, so:
|
||||||
|
// seed with the spot rate, quote once, re-solve, and re-quote when the answer
|
||||||
|
// moved — 2 quotes converge well because q̂ varies slowly with s. Residual
|
||||||
|
// mismatch (price impact shifting the band ratio) surfaces as DUST: a small
|
||||||
|
// leftover the deposit doesn't pull, which stays in the wallet. Planning uses
|
||||||
|
// floats (ratios only); every on-chain amount stays bigint.
|
||||||
|
import type { Address } from 'viem'
|
||||||
|
import { readContract, sendTransaction, writeContract } from 'wagmi/actions'
|
||||||
|
import { clPmAbi, uniV2PairAbi, uniV2RouterAbi, uniV3PmAbi, v2RouterAbi, wethAbi } from '../abi'
|
||||||
|
import { ADDR, CHAIN_ID, UNI } from '../config/addresses'
|
||||||
|
import { ENV } from '../config/env'
|
||||||
|
import { wagmiConfig } from '../config/wagmi'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import {
|
||||||
|
applySlippage,
|
||||||
|
getAmountsForLiquidity,
|
||||||
|
getLiquidityForAmounts,
|
||||||
|
getSqrtRatioAtTick,
|
||||||
|
minAmountsForLiquidity,
|
||||||
|
Q96,
|
||||||
|
} from './clmath'
|
||||||
|
import { fmtAmount } from './format'
|
||||||
|
import { kyberRoute, NATIVE, type KyberRouteData } from './kyber'
|
||||||
|
import { buildGatedKyberTx } from './kyberExec'
|
||||||
|
import { deadline, ensureAllowance, fetchSqrtPriceX96, receivedOf, step } from './tx'
|
||||||
|
import { txlog } from './txlog'
|
||||||
|
import type { ClPool, Pool, TokenInfo, V2Pool } from '../types'
|
||||||
|
|
||||||
|
const MINS_BPS = 100 // deposit mins: 1% band-edge, same as the PAIR flows
|
||||||
|
|
||||||
|
export type ZapTarget =
|
||||||
|
| { kind: 'cl-mint'; pool: ClPool; tickLower: number; tickUpper: number }
|
||||||
|
| { kind: 'cl-increase'; pool: ClPool; tickLower: number; tickUpper: number; tokenId: bigint; npm: Address }
|
||||||
|
| { kind: 'v2'; pool: V2Pool }
|
||||||
|
|
||||||
|
export type ZapPlan = {
|
||||||
|
/** what the user selected (NATIVE sentinel allowed) */
|
||||||
|
tokenIn: Address
|
||||||
|
nativeIn: boolean
|
||||||
|
/** erc20 actually spent (WETH when nativeIn) */
|
||||||
|
erc20In: Address
|
||||||
|
inIs0: boolean
|
||||||
|
amountIn: bigint
|
||||||
|
swapIn: bigint
|
||||||
|
keep: bigint
|
||||||
|
route: KyberRouteData | null // final planning quote (null = no swap needed)
|
||||||
|
estOut: bigint
|
||||||
|
dep0: bigint
|
||||||
|
dep1: bigint
|
||||||
|
/** CL only: liquidity the planned deposit mints at the planning price */
|
||||||
|
liquidity: bigint
|
||||||
|
dust0: bigint
|
||||||
|
dust1: bigint
|
||||||
|
impactBps: number | null // swap value lost to impact+lp fees (platform fee excluded)
|
||||||
|
routeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function poolOf(tgt: ZapTarget): Pool {
|
||||||
|
return tgt.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const low = (a: string) => a.toLowerCase()
|
||||||
|
const isNat = (a: Address) => low(a) === low(NATIVE)
|
||||||
|
|
||||||
|
/** needed raw-unit ratio (counter per kept) + spot rate seed, by target */
|
||||||
|
function needAndSpot(tgt: ZapTarget, inIs0: boolean): { rho: number; spot: number } {
|
||||||
|
const pool = poolOf(tgt)
|
||||||
|
if (pool.kind === 'v2') {
|
||||||
|
const r0 = Number(pool.reserve0)
|
||||||
|
const r1 = Number(pool.reserve1)
|
||||||
|
if (!(r0 > 0) || !(r1 > 0)) throw new Error(t('zap.errNoReserves'))
|
||||||
|
const rho = inIs0 ? r1 / r0 : r0 / r1
|
||||||
|
return { rho, spot: rho } // marginal v2 price == reserve ratio
|
||||||
|
}
|
||||||
|
const { tickLower, tickUpper } = tgt as Extract<ZapTarget, { kind: 'cl-mint' | 'cl-increase' }>
|
||||||
|
const p = Number(pool.sqrtPriceX96) / Number(Q96)
|
||||||
|
const a = Number(getSqrtRatioAtTick(tickLower)) / Number(Q96)
|
||||||
|
const b = Number(getSqrtRatioAtTick(tickUpper)) / Number(Q96)
|
||||||
|
if (!(p > 0)) throw new Error(t('zap.errNoPrice'))
|
||||||
|
const spot1per0 = p * p // raw token1 per raw token0
|
||||||
|
const spot = inIs0 ? spot1per0 : 1 / spot1per0
|
||||||
|
if (p <= a) return { rho: inIs0 ? 0 : Infinity, spot } // band above price: token0 only
|
||||||
|
if (p >= b) return { rho: inIs0 ? Infinity : 0, spot } // band below price: token1 only
|
||||||
|
const amt0f = (b - p) / (b * p) // token0 a unit of liquidity holds
|
||||||
|
const amt1f = p - a // token1 a unit of liquidity holds
|
||||||
|
const rho1per0 = amt1f / amt0f
|
||||||
|
return { rho: inIs0 ? rho1per0 : 1 / rho1per0, spot }
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveSwap(amountIn: bigint, rho: number, rate: number): bigint {
|
||||||
|
if (rho === 0) return 0n
|
||||||
|
if (!Number.isFinite(rho)) return amountIn
|
||||||
|
const A = Number(amountIn)
|
||||||
|
const s = (A * rho) / (rate + rho)
|
||||||
|
const sb = BigInt(Math.round(Math.min(Math.max(s, 0), A)))
|
||||||
|
return sb > amountIn ? amountIn : sb
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan a zap. Throws Error with a human-readable reason when it can't
|
||||||
|
* (no route, empty pool, dust amounts). Network: 1–2 kyber quotes.
|
||||||
|
*/
|
||||||
|
export async function planZap(args: {
|
||||||
|
target: ZapTarget
|
||||||
|
tokenIn: Address // NATIVE | pool.token0 | pool.token1
|
||||||
|
amountIn: bigint
|
||||||
|
signal?: AbortSignal
|
||||||
|
}): Promise<ZapPlan> {
|
||||||
|
const { target, tokenIn, amountIn } = args
|
||||||
|
const pool = poolOf(target)
|
||||||
|
if (amountIn <= 0n) throw new Error(t('zap.errAmount'))
|
||||||
|
const nativeIn = isNat(tokenIn)
|
||||||
|
const erc20In = nativeIn ? ADDR.WETH : tokenIn
|
||||||
|
const inIs0 = low(erc20In) === low(pool.token0)
|
||||||
|
if (!inIs0 && low(erc20In) !== low(pool.token1)) throw new Error(t('zap.errNotInPool'))
|
||||||
|
const otherErc20 = inIs0 ? pool.token1 : pool.token0
|
||||||
|
|
||||||
|
const { rho, spot } = needAndSpot(target, inIs0)
|
||||||
|
|
||||||
|
// --- solve the swap size (≤2 kyber quotes) ---
|
||||||
|
let swapIn = solveSwap(amountIn, rho, spot)
|
||||||
|
let route: KyberRouteData | null = null
|
||||||
|
if (swapIn > 0n && swapIn * 1_000_000n < amountIn) swapIn = 0n // <0.0001% — not worth a tx
|
||||||
|
if (amountIn - swapIn > 0n && (amountIn - swapIn) * 1_000_000n < amountIn) swapIn = amountIn
|
||||||
|
if (swapIn > 0n) {
|
||||||
|
route = await kyberRoute(erc20In, otherErc20, swapIn, { signal: args.signal })
|
||||||
|
const q1 = Number(BigInt(route.routeSummary.amountOut)) / Number(swapIn)
|
||||||
|
if (!(q1 > 0)) throw new Error(t('zap.errZeroQuote'))
|
||||||
|
const s1 = solveSwap(amountIn, rho, q1)
|
||||||
|
// re-quote only when the answer moved meaningfully (>0.4% of the input)
|
||||||
|
const drift = s1 > swapIn ? s1 - swapIn : swapIn - s1
|
||||||
|
if (drift * 250n > amountIn && s1 > 0n) {
|
||||||
|
swapIn = s1
|
||||||
|
route = await kyberRoute(erc20In, otherErc20, swapIn, { signal: args.signal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const estOut = route ? BigInt(route.routeSummary.amountOut) : 0n
|
||||||
|
if (swapIn > 0n && estOut === 0n) throw new Error(t('zap.errNoRoute'))
|
||||||
|
|
||||||
|
// --- planned deposit + dust estimate ---
|
||||||
|
const keep = amountIn - swapIn
|
||||||
|
const dep0 = inIs0 ? keep : estOut
|
||||||
|
const dep1 = inIs0 ? estOut : keep
|
||||||
|
let liquidity = 0n
|
||||||
|
let dust0 = 0n
|
||||||
|
let dust1 = 0n
|
||||||
|
if (pool.kind === 'cl') {
|
||||||
|
const { tickLower, tickUpper } = target as Extract<ZapTarget, { kind: 'cl-mint' | 'cl-increase' }>
|
||||||
|
const sqrtA = getSqrtRatioAtTick(tickLower)
|
||||||
|
const sqrtB = getSqrtRatioAtTick(tickUpper)
|
||||||
|
liquidity = getLiquidityForAmounts(pool.sqrtPriceX96, sqrtA, sqrtB, dep0, dep1)
|
||||||
|
if (liquidity === 0n) throw new Error(t('zap.errTooSmall'))
|
||||||
|
const pulled = getAmountsForLiquidity(pool.sqrtPriceX96, sqrtA, sqrtB, liquidity)
|
||||||
|
dust0 = dep0 > pulled.amount0 ? dep0 - pulled.amount0 : 0n
|
||||||
|
dust1 = dep1 > pulled.amount1 ? dep1 - pulled.amount1 : 0n
|
||||||
|
} else {
|
||||||
|
const v2 = pool as V2Pool
|
||||||
|
if (dep0 > 0n && dep1 > 0n) {
|
||||||
|
const need1 = (dep0 * v2.reserve1) / v2.reserve0
|
||||||
|
if (need1 <= dep1) dust1 = dep1 - need1
|
||||||
|
else dust0 = dep0 - (dep1 * v2.reserve0) / v2.reserve1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swap value lost to impact + lp fees, from kyber's own USD marks; the
|
||||||
|
// configured platform fee (if any) is subtracted so it doesn't read as impact
|
||||||
|
let impactBps: number | null = null
|
||||||
|
const inUsd = Number(route?.routeSummary.amountInUsd ?? NaN)
|
||||||
|
const outUsd = Number(route?.routeSummary.amountOutUsd ?? NaN)
|
||||||
|
if (inUsd > 0 && outUsd > 0) impactBps = (1 - outUsd / inUsd) * 10_000 - ENV.kyberFeeBps
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenIn,
|
||||||
|
nativeIn,
|
||||||
|
erc20In,
|
||||||
|
inIs0,
|
||||||
|
amountIn,
|
||||||
|
swapIn,
|
||||||
|
keep,
|
||||||
|
route,
|
||||||
|
estOut,
|
||||||
|
dep0,
|
||||||
|
dep1,
|
||||||
|
liquidity,
|
||||||
|
dust0,
|
||||||
|
dust1,
|
||||||
|
impactBps,
|
||||||
|
routeLabel: route ? routeLabelOf(route) : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLabelOf(r: KyberRouteData): string {
|
||||||
|
const names = new Set<string>()
|
||||||
|
for (const path of r.routeSummary.route ?? []) for (const h of path) names.add(h.exchange)
|
||||||
|
return [...names].slice(0, 3).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- stages ----------------
|
||||||
|
|
||||||
|
export type ZapStageKind = 'wrap' | 'approveIn' | 'swap' | 'approve0' | 'approve1' | 'deposit'
|
||||||
|
export type ZapStage = { kind: ZapStageKind; label: string }
|
||||||
|
|
||||||
|
const depositVerb = (tgt: ZapTarget): string =>
|
||||||
|
tgt.kind === 'cl-increase'
|
||||||
|
? t('zap.stIncrease', { id: tgt.tokenId.toString() })
|
||||||
|
: tgt.kind === 'cl-mint'
|
||||||
|
? t('zap.stMint')
|
||||||
|
: t('zap.stAddLiquidity')
|
||||||
|
|
||||||
|
/** the exact tx sequence executeZap will run, for preview + progress UI */
|
||||||
|
export function zapStages(plan: ZapPlan, target: ZapTarget, t0: TokenInfo, t1: TokenInfo): ZapStage[] {
|
||||||
|
const tIn = plan.inIs0 ? t0 : t1
|
||||||
|
const tOut = plan.inIs0 ? t1 : t0
|
||||||
|
const spender = target.kind === 'v2' ? t('zap.spenderRouter') : t('zap.spenderNpm')
|
||||||
|
const stages: ZapStage[] = []
|
||||||
|
if (plan.nativeIn) stages.push({ kind: 'wrap', label: t('zap.stWrap', { amt: fmtAmount(plan.amountIn, 18) }) })
|
||||||
|
if (plan.swapIn > 0n) {
|
||||||
|
stages.push({ kind: 'approveIn', label: t('zap.stApproveKyber', { sym: tIn.symbol }) })
|
||||||
|
stages.push({
|
||||||
|
kind: 'swap',
|
||||||
|
label: t('zap.stSwap', {
|
||||||
|
amt: fmtAmount(plan.swapIn, tIn.decimals),
|
||||||
|
sym: tIn.symbol,
|
||||||
|
out: fmtAmount(plan.estOut, tOut.decimals),
|
||||||
|
outSym: tOut.symbol,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (plan.dep0 > 0n) stages.push({ kind: 'approve0', label: t('zap.stApproveSpender', { sym: t0.symbol, spender }) })
|
||||||
|
if (plan.dep1 > 0n) stages.push({ kind: 'approve1', label: t('zap.stApproveSpender', { sym: t1.symbol, spender }) })
|
||||||
|
stages.push({ kind: 'deposit', label: depositVerb(target) })
|
||||||
|
return stages
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- executor ----------------
|
||||||
|
|
||||||
|
export type ZapRun = { ok: boolean; failedAt: number | null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a planned zap step by step. Re-quotes the swap fresh (the plan's
|
||||||
|
* quote is for preview) and deposits the amounts that ACTUALLY arrived, so a
|
||||||
|
* stale plan can only halt the flow, never mis-spend. Halts (returns
|
||||||
|
* failedAt) on the first failed/rejected tx or violated gate.
|
||||||
|
*/
|
||||||
|
export async function executeZap(args: {
|
||||||
|
plan: ZapPlan
|
||||||
|
target: ZapTarget
|
||||||
|
user: Address
|
||||||
|
slipBps: number // swap leg slippage
|
||||||
|
t0: TokenInfo
|
||||||
|
t1: TokenInfo
|
||||||
|
onStage?: (i: number) => void
|
||||||
|
}): Promise<ZapRun> {
|
||||||
|
const { plan, target, user, slipBps, t0, t1 } = args
|
||||||
|
const pool = poolOf(target)
|
||||||
|
const stages = zapStages(plan, target, t0, t1)
|
||||||
|
const tIn = plan.inIs0 ? t0 : t1
|
||||||
|
const otherErc20 = plan.inIs0 ? pool.token1 : pool.token0
|
||||||
|
let i = 0
|
||||||
|
const fail = (): ZapRun => ({ ok: false, failedAt: i })
|
||||||
|
const abort = (msg: string): ZapRun => {
|
||||||
|
txlog.push('err', t('zap.halt', { msg }))
|
||||||
|
return fail()
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualOut = 0n
|
||||||
|
|
||||||
|
for (i = 0; i < stages.length; i++) {
|
||||||
|
args.onStage?.(i)
|
||||||
|
const st = stages[i]
|
||||||
|
switch (st.kind) {
|
||||||
|
case 'wrap': {
|
||||||
|
const h = await step(st.label, () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: wethAbi,
|
||||||
|
address: ADDR.WETH,
|
||||||
|
functionName: 'deposit',
|
||||||
|
value: plan.amountIn,
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!h) return fail()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'approveIn': {
|
||||||
|
if (!(await ensureAllowance(plan.erc20In, user, ENV.kyberRouter, plan.swapIn, tIn.symbol))) return fail()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'swap': {
|
||||||
|
// fresh quote for the build; the plan's quote is preview-only
|
||||||
|
let fresh
|
||||||
|
try {
|
||||||
|
fresh = await kyberRoute(plan.erc20In, otherErc20, plan.swapIn)
|
||||||
|
} catch (e) {
|
||||||
|
return abort(t('zap.haltRequote', { err: (e as Error).message }))
|
||||||
|
}
|
||||||
|
const freshOut = BigInt(fresh.routeSummary.amountOut)
|
||||||
|
// price-move gate: the fresh route must still deliver ≈ the previewed
|
||||||
|
// output (slippage + 0.5% grace) — otherwise stop and let the user re-look
|
||||||
|
if (freshOut < applySlippage(plan.estOut, slipBps + 50)) {
|
||||||
|
const dec = (plan.inIs0 ? t1 : t0).decimals
|
||||||
|
return abort(
|
||||||
|
t('zap.haltPriceMoved', { now: fmtAmount(freshOut, dec), prev: fmtAmount(plan.estOut, dec) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// tokenOut identity gate: the route must pay out the pool's counter-token
|
||||||
|
if (low(fresh.routeSummary.tokenOut) !== low(otherErc20)) {
|
||||||
|
return abort(t('zap.haltTokenOut', { addr: fresh.routeSummary.tokenOut }))
|
||||||
|
}
|
||||||
|
let tx
|
||||||
|
try {
|
||||||
|
tx = await buildGatedKyberTx({
|
||||||
|
routeSummary: fresh.routeSummary,
|
||||||
|
sender: user,
|
||||||
|
recipient: user,
|
||||||
|
slippageBps: slipBps,
|
||||||
|
amountIn: plan.swapIn,
|
||||||
|
nativeIn: false, // zap always swaps the erc20 (ETH was wrapped in stage 0)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return abort((e as Error).message)
|
||||||
|
}
|
||||||
|
// read what actually arrived (receipt Transfer logs) — deposits use this
|
||||||
|
let got = 0n
|
||||||
|
const h = await step(
|
||||||
|
st.label + ' [KYBER]',
|
||||||
|
() => sendTransaction(wagmiConfig, { to: tx.to, data: tx.data, value: tx.value, chainId: CHAIN_ID }),
|
||||||
|
{ onSuccess: (rcpt) => (got = receivedOf(rcpt, otherErc20, user)) },
|
||||||
|
)
|
||||||
|
if (!h) return fail()
|
||||||
|
if (got === 0n) return abort(t('zap.haltNoTransfer'))
|
||||||
|
actualOut = got
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'approve0':
|
||||||
|
case 'approve1': {
|
||||||
|
const is0 = st.kind === 'approve0'
|
||||||
|
const token = is0 ? pool.token0 : pool.token1
|
||||||
|
const sym = is0 ? t0.symbol : t1.symbol
|
||||||
|
const amt = depositAmounts(plan, actualOut)[is0 ? 0 : 1]
|
||||||
|
const spender =
|
||||||
|
target.kind === 'v2'
|
||||||
|
? (pool as V2Pool).protocol === 'univ2'
|
||||||
|
? UNI.V2_ROUTER
|
||||||
|
: ADDR.V2_ROUTER
|
||||||
|
: target.kind === 'cl-increase'
|
||||||
|
? target.npm
|
||||||
|
: (pool as ClPool).protocol === 'univ3'
|
||||||
|
? UNI.V3_NPM
|
||||||
|
: ADDR.CL_PM
|
||||||
|
if (amt > 0n && !(await ensureAllowance(token, user, spender, amt, sym))) return fail()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'deposit': {
|
||||||
|
const [dep0, dep1] = depositAmounts(plan, actualOut)
|
||||||
|
if (dep0 === 0n && dep1 === 0n) return abort(t('zap.haltNothing'))
|
||||||
|
const ok = await runDeposit(target, user, dep0, dep1, st.label, t0, t1)
|
||||||
|
if (!ok) return fail()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// zapped into an up33 pool with a live gauge → staking is the follow-up move
|
||||||
|
if (pool.protocol === 'up33' && pool.gauge && pool.gaugeAlive && target.kind !== 'cl-increase') {
|
||||||
|
txlog.push('info', t('zap.stakeHint'), undefined, {
|
||||||
|
label: t('zap.stakeHintAction'),
|
||||||
|
onClick: () => {
|
||||||
|
location.hash = 'positions'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { ok: true, failedAt: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** post-swap deposit amounts: kept side exact, swapped side = what arrived */
|
||||||
|
function depositAmounts(plan: ZapPlan, actualOut: bigint): [bigint, bigint] {
|
||||||
|
const out = plan.swapIn > 0n ? actualOut : 0n
|
||||||
|
return plan.inIs0 ? [plan.keep, out] : [out, plan.keep]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDeposit(
|
||||||
|
target: ZapTarget,
|
||||||
|
user: Address,
|
||||||
|
dep0: bigint,
|
||||||
|
dep1: bigint,
|
||||||
|
label: string,
|
||||||
|
t0: TokenInfo,
|
||||||
|
t1: TokenInfo,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (target.kind === 'v2') {
|
||||||
|
const pool = target.pool
|
||||||
|
if (pool.protocol === 'univ2') {
|
||||||
|
// vanilla Router02 has no quote helper — compute the optimal pair from
|
||||||
|
// fresh reserves so mins are honest and dust stays in the wallet
|
||||||
|
const [r0, r1] = await readContract(wagmiConfig, {
|
||||||
|
abi: uniV2PairAbi,
|
||||||
|
address: pool.address,
|
||||||
|
functionName: 'getReserves',
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
})
|
||||||
|
let d0 = dep0
|
||||||
|
let d1 = dep1
|
||||||
|
if (r0 > 0n && r1 > 0n && dep0 > 0n && dep1 > 0n) {
|
||||||
|
const need1 = (dep0 * r1) / r0
|
||||||
|
if (need1 <= dep1) d1 = need1
|
||||||
|
else d0 = (dep1 * r0) / r1
|
||||||
|
}
|
||||||
|
const h = await step(`${label} ${t0.symbol}/${t1.symbol} [uni v2]`, () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: uniV2RouterAbi,
|
||||||
|
address: UNI.V2_ROUTER,
|
||||||
|
functionName: 'addLiquidity',
|
||||||
|
args: [pool.token0, pool.token1, d0, d1, applySlippage(d0, MINS_BPS), applySlippage(d1, MINS_BPS), user, deadline()],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return h !== null
|
||||||
|
}
|
||||||
|
const quote = await readContract(wagmiConfig, {
|
||||||
|
abi: v2RouterAbi,
|
||||||
|
address: ADDR.V2_ROUTER,
|
||||||
|
functionName: 'quoteAddLiquidity',
|
||||||
|
args: [pool.token0, pool.token1, pool.stable, ADDR.V2_FACTORY, dep0, dep1],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
})
|
||||||
|
const h = await step(`${label} ${t0.symbol}/${t1.symbol}`, () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: v2RouterAbi,
|
||||||
|
address: ADDR.V2_ROUTER,
|
||||||
|
functionName: 'addLiquidity',
|
||||||
|
args: [
|
||||||
|
pool.token0,
|
||||||
|
pool.token1,
|
||||||
|
pool.stable,
|
||||||
|
dep0,
|
||||||
|
dep1,
|
||||||
|
applySlippage(quote[0], MINS_BPS),
|
||||||
|
applySlippage(quote[1], MINS_BPS),
|
||||||
|
user,
|
||||||
|
deadline(),
|
||||||
|
],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return h !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
// CL: fresh price + band-edge mins (see minAmountsForLiquidity) — 'PS'-safe
|
||||||
|
const pool = target.pool
|
||||||
|
const sqrtP = await fetchSqrtPriceX96(pool.address)
|
||||||
|
const sqrtA = getSqrtRatioAtTick(target.tickLower)
|
||||||
|
const sqrtB = getSqrtRatioAtTick(target.tickUpper)
|
||||||
|
const liq = getLiquidityForAmounts(sqrtP, sqrtA, sqrtB, dep0, dep1)
|
||||||
|
if (liq === 0n) {
|
||||||
|
txlog.push('err', t('zap.halt', { msg: t('zap.haltDepositSmall') }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const mins = minAmountsForLiquidity(sqrtP, sqrtA, sqrtB, liq, MINS_BPS)
|
||||||
|
|
||||||
|
if (target.kind === 'cl-increase') {
|
||||||
|
const h = await step(`${label} (${t0.symbol}/${t1.symbol})`, () =>
|
||||||
|
writeContract(wagmiConfig, {
|
||||||
|
abi: clPmAbi,
|
||||||
|
address: target.npm,
|
||||||
|
functionName: 'increaseLiquidity',
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
tokenId: target.tokenId,
|
||||||
|
amount0Desired: dep0,
|
||||||
|
amount1Desired: dep1,
|
||||||
|
amount0Min: mins.amount0Min,
|
||||||
|
amount1Min: mins.amount1Min,
|
||||||
|
deadline: deadline(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return h !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
token0: pool.token0,
|
||||||
|
token1: pool.token1,
|
||||||
|
tickLower: target.tickLower,
|
||||||
|
tickUpper: target.tickUpper,
|
||||||
|
amount0Desired: dep0,
|
||||||
|
amount1Desired: dep1,
|
||||||
|
amount0Min: mins.amount0Min,
|
||||||
|
amount1Min: mins.amount1Min,
|
||||||
|
recipient: user,
|
||||||
|
deadline: deadline(),
|
||||||
|
}
|
||||||
|
const h = await step(
|
||||||
|
`${label} ${pool.protocol === 'univ3' ? 'v3' : 'CL'} ${t0.symbol}/${t1.symbol} [${target.tickLower},${target.tickUpper}]`,
|
||||||
|
() =>
|
||||||
|
pool.protocol === 'univ3'
|
||||||
|
? writeContract(wagmiConfig, {
|
||||||
|
abi: uniV3PmAbi,
|
||||||
|
address: UNI.V3_NPM,
|
||||||
|
functionName: 'mint',
|
||||||
|
args: [{ ...common, fee: pool.feePpm }],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
})
|
||||||
|
: writeContract(wagmiConfig, {
|
||||||
|
abi: clPmAbi,
|
||||||
|
address: ADDR.CL_PM,
|
||||||
|
functionName: 'mint',
|
||||||
|
args: [{ ...common, tickSpacing: pool.tickSpacing, sqrtPriceX96: 0n }],
|
||||||
|
chainId: CHAIN_ID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return h !== null
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { ccipRequest } from 'viem/utils'
|
||||||
|
import '@rainbow-me/rainbowkit/styles.css'
|
||||||
|
import './styles.css'
|
||||||
|
import { t } from './i18n' // init before anything renders or pushes txlog lines
|
||||||
|
import App from './App'
|
||||||
|
import { txlog } from './lib/txlog'
|
||||||
|
|
||||||
|
// viem lazy-imports its ccip module inside EVERY eth_call error path (the
|
||||||
|
// import happens before the OffchainLookup selector check), which made it the
|
||||||
|
// one chunk this app fetches mid-error. After a redeploy, a tab opened before
|
||||||
|
// the deploy 404s the old hash and the module error MASKS the real revert
|
||||||
|
// (seen live 2026-07-16: a Kyber swap revert surfaced as "Failed to fetch
|
||||||
|
// dynamically imported module ccip-*.js"). Referencing the module here folds
|
||||||
|
// it into the eager bundle so it can never go stale.
|
||||||
|
;(globalThis as Record<string, unknown>).__viemCcipEagerPin = ccipRequest
|
||||||
|
|
||||||
|
// Other lazy chunks (wallet SDKs, RainbowKit locales) can still 404 in a tab
|
||||||
|
// that outlives a deploy. Vite surfaces those failures as vite:preloadError —
|
||||||
|
// reload once to pick up the new build instead of failing the user's action.
|
||||||
|
// The timestamp guard stops reload loops on a genuinely broken build.
|
||||||
|
const RELOAD_KEY = 'up33.staleChunkReload'
|
||||||
|
window.addEventListener('vite:preloadError', (e) => {
|
||||||
|
const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0)
|
||||||
|
if (Date.now() - last < 60_000) return // just reloaded — let the error surface
|
||||||
|
sessionStorage.setItem(RELOAD_KEY, String(Date.now()))
|
||||||
|
e.preventDefault()
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0)
|
||||||
|
if (last && Date.now() - last < 15_000) {
|
||||||
|
txlog.push('info', t('app.reloaded'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
+781
@@ -0,0 +1,781 @@
|
|||||||
|
/* ---------- theme system ----------
|
||||||
|
--acc* identity accent (brand, active states, buttons)
|
||||||
|
--range* LP-range semantics (range bars/markers) — green family everywhere
|
||||||
|
--green/--red/--amber SEMANTIC only (up/down, ok/err, warn) — same role in
|
||||||
|
every theme; --cyan = secondary info (links, type labels).
|
||||||
|
Theme selection: <html data-theme="…"> (persisted by src/lib/theme.ts,
|
||||||
|
applied pre-paint by index.html). :root doubles as the MONO default —
|
||||||
|
true black, white identity, color reserved for meaning (user's spec). */
|
||||||
|
:root {
|
||||||
|
--bg: #000000;
|
||||||
|
--panel: #0b0b0b;
|
||||||
|
--panel2: #141414;
|
||||||
|
--inset: #060606;
|
||||||
|
--line: #232323;
|
||||||
|
--line-hi: #3d3d3d;
|
||||||
|
--fg: #ffffff;
|
||||||
|
--dim: #8f8f8f;
|
||||||
|
--placeholder: #4a4a4a;
|
||||||
|
--acc: #ffffff;
|
||||||
|
--acc-dim: #6e6e6e;
|
||||||
|
--acc-fg: #000000;
|
||||||
|
--acc-rgb: 255, 255, 255;
|
||||||
|
--green: #4ade80;
|
||||||
|
--green-dim: #2f9e57;
|
||||||
|
--amber: #ffc266;
|
||||||
|
--red: #ff5555;
|
||||||
|
--cyan: #c9c9c9;
|
||||||
|
--banner-bg: #170b0b;
|
||||||
|
--range: var(--green); /* LP ranges stay green in every theme */
|
||||||
|
--range-rgb: 74, 222, 128;
|
||||||
|
--crt: 0; /* scanline+vignette opacity (0 disables the CRT layer) */
|
||||||
|
font-size: 13.5px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* phosphor-green CRT — the original UP33 look */
|
||||||
|
:root[data-theme='phosphor'] {
|
||||||
|
--bg: #080b09;
|
||||||
|
--panel: #0f1511;
|
||||||
|
--panel2: #141c16;
|
||||||
|
--inset: #060a07;
|
||||||
|
--line: #263628;
|
||||||
|
--line-hi: #3d5a44;
|
||||||
|
--fg: #eaf4ea;
|
||||||
|
--dim: #96ab98;
|
||||||
|
--placeholder: #3d4a3d;
|
||||||
|
--acc: #52ff2e;
|
||||||
|
--acc-dim: #34ad4b;
|
||||||
|
--acc-fg: #061006;
|
||||||
|
--acc-rgb: 82, 255, 46;
|
||||||
|
--green: #52ff2e;
|
||||||
|
--green-dim: #34ad4b;
|
||||||
|
--amber: #ffbb60;
|
||||||
|
--red: #ff5c5c;
|
||||||
|
--cyan: #66dee6;
|
||||||
|
--banner-bg: #1a0d0d;
|
||||||
|
--range-rgb: 82, 255, 46;
|
||||||
|
--crt: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* warm VT220 — amber identity, cooler green kept for "positive" semantics */
|
||||||
|
:root[data-theme='amber'] {
|
||||||
|
--bg: #0b0805;
|
||||||
|
--panel: #14100a;
|
||||||
|
--panel2: #1a150d;
|
||||||
|
--inset: #080604;
|
||||||
|
--line: #33291a;
|
||||||
|
--line-hi: #57452a;
|
||||||
|
--fg: #f4e9d4;
|
||||||
|
--dim: #a99878;
|
||||||
|
--placeholder: #4a3f2c;
|
||||||
|
--acc: #ffb000;
|
||||||
|
--acc-dim: #b07d17;
|
||||||
|
--acc-fg: #140d02;
|
||||||
|
--acc-rgb: 255, 176, 0;
|
||||||
|
--green: #58d68d;
|
||||||
|
--green-dim: #3a8f5f;
|
||||||
|
--amber: #ffd075;
|
||||||
|
--red: #ff6857;
|
||||||
|
--cyan: #e0c088;
|
||||||
|
--banner-bg: #1d0d08;
|
||||||
|
--range-rgb: 88, 214, 141;
|
||||||
|
--crt: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cool quant desk — ice-cyan identity on blue-black, no CRT layer */
|
||||||
|
:root[data-theme='ice'] {
|
||||||
|
--bg: #06090d;
|
||||||
|
--panel: #0b111a;
|
||||||
|
--panel2: #101825;
|
||||||
|
--inset: #04070b;
|
||||||
|
--line: #1d2a3a;
|
||||||
|
--line-hi: #31485f;
|
||||||
|
--fg: #e6eef7;
|
||||||
|
--dim: #8298ad;
|
||||||
|
--placeholder: #34455a;
|
||||||
|
--acc: #4dc9ff;
|
||||||
|
--acc-dim: #2b83a8;
|
||||||
|
--acc-fg: #041019;
|
||||||
|
--acc-rgb: 77, 201, 255;
|
||||||
|
--green: #43e08a;
|
||||||
|
--green-dim: #2b9b60;
|
||||||
|
--amber: #ffc266;
|
||||||
|
--red: #ff5f6e;
|
||||||
|
--cyan: #9db8d4;
|
||||||
|
--banner-bg: #1a0d10;
|
||||||
|
--range-rgb: 67, 224, 138;
|
||||||
|
--crt: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* modern minimal — violet identity on neutral dark, no CRT layer */
|
||||||
|
:root[data-theme='violet'] {
|
||||||
|
--bg: #0b0a0e;
|
||||||
|
--panel: #121016;
|
||||||
|
--panel2: #18151e;
|
||||||
|
--inset: #08070b;
|
||||||
|
--line: #262230;
|
||||||
|
--line-hi: #3e3652;
|
||||||
|
--fg: #eeeaf4;
|
||||||
|
--dim: #9d94ad;
|
||||||
|
--placeholder: #453d56;
|
||||||
|
--acc: #b18cff;
|
||||||
|
--acc-dim: #7a5cc0;
|
||||||
|
--acc-fg: #120a20;
|
||||||
|
--acc-rgb: 177, 140, 255;
|
||||||
|
--green: #4ade80;
|
||||||
|
--green-dim: #2f9e57;
|
||||||
|
--amber: #fbbf6b;
|
||||||
|
--red: #ff5c74;
|
||||||
|
--cyan: #8fd0e8;
|
||||||
|
--banner-bg: #190d14;
|
||||||
|
--range-rgb: 74, 222, 128;
|
||||||
|
--crt: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body, #root { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { background: var(--acc); color: var(--acc-fg); }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--line-hi); }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--acc-dim); }
|
||||||
|
|
||||||
|
a { color: var(--cyan); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
button, input, select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* terminal STYLE, not a terminal-sized box: full viewport HEIGHT (table
|
||||||
|
scrolls to the bottom edge), width generous but bounded — unbounded rows
|
||||||
|
were too long to scan, so content clusters inside side gutters */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 1520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 10px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- header ---------- */
|
||||||
|
.hdr {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 8px 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.brand { color: var(--acc); font-weight: 700; letter-spacing: 1px; white-space: nowrap; }
|
||||||
|
.brand .cursor { animation: blink 1.1s steps(1) infinite; }
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* direction-aware update flash (Flash component) — semantic, theme-invariant */
|
||||||
|
.flash { border-radius: 2px; }
|
||||||
|
.flash-up { animation: flashUp 1.1s ease-out; }
|
||||||
|
.flash-down { animation: flashDown 1.1s ease-out; }
|
||||||
|
.flash-arrow { font-size: 0.8em; margin-left: 3px; }
|
||||||
|
@keyframes flashUp {
|
||||||
|
0% { background: rgba(80, 220, 90, 0.3); }
|
||||||
|
100% { background: transparent; }
|
||||||
|
}
|
||||||
|
@keyframes flashDown {
|
||||||
|
0% { background: rgba(255, 92, 92, 0.32); }
|
||||||
|
100% { background: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs { display: flex; gap: 4px; flex: 1; flex-wrap: wrap; }
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--fg); border-color: var(--line); }
|
||||||
|
.tab.active { color: var(--acc); border-color: var(--acc-dim); background: var(--panel2); }
|
||||||
|
.tab .key { color: var(--dim); font-size: 0.85em; margin-right: 5px; }
|
||||||
|
|
||||||
|
.hdr-meta { color: var(--dim); font-size: 0.9em; white-space: nowrap; }
|
||||||
|
.hdr-meta b { color: var(--fg); font-weight: 400; }
|
||||||
|
|
||||||
|
/* ---------- generic ---------- */
|
||||||
|
.btn {
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
color: var(--acc);
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) { background: var(--acc); color: var(--acc-fg); border-color: var(--acc); }
|
||||||
|
.btn:disabled { color: var(--dim); border-color: var(--line); cursor: not-allowed; }
|
||||||
|
.btn.danger { color: var(--red); }
|
||||||
|
.btn.danger:hover:not(:disabled) { background: var(--red); color: #000; border-color: var(--red); }
|
||||||
|
.btn.ghost { color: var(--dim); border-color: var(--line); }
|
||||||
|
.btn.ghost:hover:not(:disabled) { background: var(--line); color: var(--fg); }
|
||||||
|
.btn.big { padding: 8px 16px; font-weight: 700; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.badge.green { color: var(--green); border-color: var(--green-dim); }
|
||||||
|
.badge.amber { color: var(--amber); border-color: var(--amber); }
|
||||||
|
.badge.red { color: var(--red); border-color: var(--red); }
|
||||||
|
.badge.cyan { color: var(--cyan); border-color: var(--cyan); }
|
||||||
|
|
||||||
|
/* protocol identity badge: brand mark + colored label (uni pink is a brand
|
||||||
|
constant; the house protocol rides the theme accent) */
|
||||||
|
.badge.proto { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.badge.proto svg { height: 10px; width: auto; display: block; }
|
||||||
|
.badge.proto img { height: 10px; width: 10px; display: block; }
|
||||||
|
.badge.proto.uni { color: #f50db4; border-color: rgba(245, 13, 180, 0.55); }
|
||||||
|
.badge.proto.up33 { color: var(--acc); border-color: var(--acc-dim); }
|
||||||
|
/* logo-only protocol mark for table rows — identity without 100× "UNISWAP" */
|
||||||
|
.proto-mini { display: inline-flex; align-items: center; margin-left: 6px; vertical-align: -1px; }
|
||||||
|
.proto-mini svg { height: 11px; width: auto; display: block; }
|
||||||
|
.proto-mini img { height: 11px; width: 11px; display: block; }
|
||||||
|
.proto-mini.uni { color: rgba(245, 13, 180, 0.8); }
|
||||||
|
.proto-mini.up33 { color: var(--acc-dim); }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
background: var(--inset);
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 5px 8px;
|
||||||
|
outline: none;
|
||||||
|
caret-color: var(--acc);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--acc-dim); }
|
||||||
|
.input::placeholder { color: var(--placeholder); }
|
||||||
|
|
||||||
|
.dim { color: var(--dim); }
|
||||||
|
.green { color: var(--green); }
|
||||||
|
.amber { color: var(--amber); }
|
||||||
|
.red { color: var(--red); }
|
||||||
|
.cyan { color: var(--cyan); }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.mono-sm { font-size: 0.88em; }
|
||||||
|
|
||||||
|
/* ---------- main ---------- */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-top: none;
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.main > * { flex: none; }
|
||||||
|
/* a tab that wants the full height (POOLS: table scrolls to the bottom edge) */
|
||||||
|
.tab-fill { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.tab-fill > * { flex: none; }
|
||||||
|
.tab-fill .tbl-wrap { flex: 1 1 auto; max-height: none; }
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
background: var(--banner-bg);
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.section-title::before { content: '┌─ '; color: var(--line-hi); }
|
||||||
|
.section-title::after { content: ' ─'; color: var(--line-hi); }
|
||||||
|
|
||||||
|
/* ---------- table ---------- */
|
||||||
|
table.tbl { width: 100%; border-collapse: collapse; }
|
||||||
|
.tbl th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--dim);
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--line-hi);
|
||||||
|
padding: 4px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tbl td { padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||||
|
.tbl tr.rowhover:hover td { background: var(--panel2); }
|
||||||
|
.tbl .num { text-align: right; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* long catalogs: the table scrolls under a sticky header row */
|
||||||
|
.tbl-wrap { overflow: auto; max-height: calc(100vh - 236px); scrollbar-gutter: stable; }
|
||||||
|
.tbl thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
z-index: 2;
|
||||||
|
/* border-bottom collapses away under sticky — repaint it with a shadow */
|
||||||
|
box-shadow: 0 1px 0 var(--line-hi);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- cards ---------- */
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel2);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card-title { color: var(--fg); font-weight: 700; }
|
||||||
|
.card-actions { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.kv { display: flex; gap: 18px; flex-wrap: wrap; margin: 4px 0; }
|
||||||
|
.kv .k { color: var(--dim); margin-right: 6px; }
|
||||||
|
|
||||||
|
.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 10px; }
|
||||||
|
|
||||||
|
.stat { border: 1px solid var(--line); padding: 8px 10px; background: var(--panel2); }
|
||||||
|
.stat .k { color: var(--dim); font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.stat .v { font-size: 1.25em; margin-top: 2px; }
|
||||||
|
.stat .sub { color: var(--dim); font-size: 0.85em; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---------- range bar ---------- */
|
||||||
|
.rbar-wrap { margin: 8px 0 4px; }
|
||||||
|
.rbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.rbar-price { white-space: nowrap; color: var(--fg); font-size: 0.92em; }
|
||||||
|
.rbar-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--inset);
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rbar-window {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(var(--range-rgb), 0.13) 0 6px,
|
||||||
|
rgba(var(--range-rgb), 0.2) 6px 7px
|
||||||
|
);
|
||||||
|
border-left: 1px solid var(--green-dim);
|
||||||
|
border-right: 1px solid var(--green-dim);
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: rbar-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards; /* draw the box on mount */
|
||||||
|
transition:
|
||||||
|
left 0.8s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
width 0.8s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
.rbar-window.out { opacity: 0.45; } /* out of range: the box recedes, the marker owns attention */
|
||||||
|
.rbar-window.warn-lo { border-left: 2px solid var(--amber); } /* the threatened bound */
|
||||||
|
.rbar-window.warn-hi { border-right: 2px solid var(--amber); }
|
||||||
|
.rbar-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
width: 3px;
|
||||||
|
transform: translateX(-1.5px);
|
||||||
|
background: var(--range);
|
||||||
|
box-shadow: 0 0 6px rgba(var(--range-rgb), 0.7);
|
||||||
|
transition: left 0.8s cubic-bezier(0.22, 1, 0.36, 1); /* glide on live updates — shows drift direction */
|
||||||
|
animation:
|
||||||
|
rbar-mark-in 0.3s ease-out 0.35s backwards,
|
||||||
|
rbar-breathe 2.8s ease-in-out 0.8s infinite; /* phosphor idle: alive, not busy */
|
||||||
|
}
|
||||||
|
.rbar-marker.amber {
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 6px var(--amber);
|
||||||
|
animation:
|
||||||
|
rbar-mark-in 0.3s ease-out 0.35s backwards,
|
||||||
|
rbar-soft 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.rbar-marker.red {
|
||||||
|
background: var(--red);
|
||||||
|
box-shadow: 0 0 7px var(--red);
|
||||||
|
animation:
|
||||||
|
rbar-mark-in 0.3s ease-out 0.35s backwards,
|
||||||
|
rbar-alarm 0.9s ease-in-out infinite; /* urgent but not a strobe */
|
||||||
|
}
|
||||||
|
/* decaying smear over the path the marker just travelled (gradient points
|
||||||
|
at the destination; .rev = it moved left) */
|
||||||
|
.rbar-trail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
color: var(--range);
|
||||||
|
background: linear-gradient(90deg, transparent, currentColor);
|
||||||
|
opacity: 0;
|
||||||
|
animation: rbar-decay 1.2s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.rbar-trail.rev { background: linear-gradient(270deg, transparent, currentColor); }
|
||||||
|
.rbar-exc {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px dashed var(--red);
|
||||||
|
opacity: 0.75;
|
||||||
|
transition:
|
||||||
|
left 0.8s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes rbar-in {
|
||||||
|
from { transform: scaleX(0); opacity: 0.4; }
|
||||||
|
to { transform: scaleX(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes rbar-mark-in { from { opacity: 0; } }
|
||||||
|
@keyframes rbar-breathe {
|
||||||
|
0%, 100% { box-shadow: 0 0 4px rgba(var(--range-rgb), 0.45); }
|
||||||
|
50% { box-shadow: 0 0 11px rgba(var(--range-rgb), 0.95); }
|
||||||
|
}
|
||||||
|
@keyframes rbar-soft { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||||
|
@keyframes rbar-alarm { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.rbar-marker, .rbar-window, .rbar-exc { transition: none; animation: none; }
|
||||||
|
.rbar-trail { display: none; }
|
||||||
|
}
|
||||||
|
.rbar-sub {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-top: 3px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rbar-flip {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
|
.rbar-flip:hover { color: var(--acc); }
|
||||||
|
|
||||||
|
/* ---------- swap ---------- */
|
||||||
|
/* single-task views sit centered in the wide main panel */
|
||||||
|
.swap-box { max-width: 720px; width: 100%; margin: 0 auto; }
|
||||||
|
.swap-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||||
|
.swap-row .lbl { width: 46px; color: var(--dim); text-transform: uppercase; font-size: 0.9em; }
|
||||||
|
.quote-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: baseline;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 7px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.quote-row:hover { border-color: var(--line-hi); }
|
||||||
|
.quote-row.sel { border-color: var(--acc-dim); background: var(--panel2); }
|
||||||
|
.quote-row .src { width: 130px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.quote-row .out { font-size: 1.05em; }
|
||||||
|
|
||||||
|
/* token select */
|
||||||
|
.tsel { position: relative; }
|
||||||
|
.tsel-btn {
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 110px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tsel-btn:hover { border-color: var(--acc-dim); }
|
||||||
|
.tsel-pop {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
width: 300px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--inset);
|
||||||
|
border: 1px solid var(--acc-dim);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.tsel-pop .filter { position: sticky; top: 0; background: var(--inset); padding: 6px; border-bottom: 1px solid var(--line); }
|
||||||
|
.tsel-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tsel-item:hover { background: var(--panel2); color: var(--acc); }
|
||||||
|
.tsel-backdrop { position: fixed; inset: 0; z-index: 20; }
|
||||||
|
|
||||||
|
/* ---------- log ---------- */
|
||||||
|
.logpanel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-top: 1px solid var(--line-hi);
|
||||||
|
background: var(--inset);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
max-height: 132px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.logline { display: flex; gap: 8px; padding: 1px 0; white-space: nowrap; }
|
||||||
|
.logline .t { color: var(--dim); }
|
||||||
|
.logline .txt { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.logline.ok .txt { color: var(--green); }
|
||||||
|
.logline.err .txt { color: var(--red); }
|
||||||
|
.logline.pending .txt { color: var(--amber); }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 6px 2px 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* footer RPC endpoint control */
|
||||||
|
.rpc-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.rpc-toggle:hover { color: var(--fg); }
|
||||||
|
.rpc-toggle .green { margin-left: 4px; }
|
||||||
|
.rpc-edit { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.rpc-edit .input { padding: 2px 6px; width: auto; }
|
||||||
|
.rpc-edit .chip { font-size: inherit; }
|
||||||
|
|
||||||
|
/* footer theme switcher */
|
||||||
|
.theme-ctl { display: inline-flex; gap: 4px; align-items: center; }
|
||||||
|
.theme-ctl .chip { padding: 0 6px; font-size: 0.95em; }
|
||||||
|
|
||||||
|
/* expander panel inside table/cards */
|
||||||
|
.expander {
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 10px;
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
.form-row { display: flex; gap: 8px; align-items: center; margin: 6px 0; flex-wrap: wrap; }
|
||||||
|
.form-row .lbl { width: 110px; color: var(--dim); font-size: 0.9em; text-transform: uppercase; }
|
||||||
|
.chip {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--fg); }
|
||||||
|
.chip.on { color: var(--acc); border-color: var(--acc-dim); background: var(--panel2); }
|
||||||
|
|
||||||
|
.spin { animation: blink 0.6s steps(1) infinite; color: var(--amber); }
|
||||||
|
|
||||||
|
/* ---------- polish layer ---------- */
|
||||||
|
|
||||||
|
/* subtle CRT scanlines + vignette (disabled per-theme via --crt) */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999;
|
||||||
|
opacity: var(--crt);
|
||||||
|
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.09) 0 1px, transparent 1px 3px);
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 998;
|
||||||
|
opacity: var(--crt);
|
||||||
|
background: radial-gradient(ellipse at center, transparent 65%, rgba(0, 0, 0, 0.3) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible { outline: 1px solid var(--acc-dim); outline-offset: 1px; }
|
||||||
|
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.brand { text-shadow: 0 0 12px rgba(var(--acc-rgb), 0.35); }
|
||||||
|
|
||||||
|
.stat .v { min-height: 1.3em; }
|
||||||
|
|
||||||
|
th.sortable { cursor: pointer; user-select: none; }
|
||||||
|
th.sortable:hover { color: var(--fg); }
|
||||||
|
th.sortable.on { color: var(--acc); }
|
||||||
|
|
||||||
|
.mydot { color: var(--acc); margin-right: 4px; }
|
||||||
|
|
||||||
|
/* pools table: compact two-line cells */
|
||||||
|
.pair-sub {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cell-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: var(--dim);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.tbl td { padding: 6px 8px; }
|
||||||
|
|
||||||
|
/* projected APR line in add-LP panels */
|
||||||
|
.sim-line {
|
||||||
|
border-top: 1px dashed var(--line-hi);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- spec box (LIMIT order ticket) ---------- */
|
||||||
|
.spec {
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
background: var(--panel2);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.spec-hd {
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.8em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 9px 10px 4px;
|
||||||
|
}
|
||||||
|
.spec-hd::before { content: '── '; color: var(--line-hi); }
|
||||||
|
.spec-hd::after { content: ' ──'; color: var(--line-hi); }
|
||||||
|
.spec-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 108px minmax(72px, max-content) 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.spec-row .sk { color: var(--dim); text-transform: uppercase; font-size: 0.9em; letter-spacing: 0.4px; }
|
||||||
|
.spec-row .sv { white-space: nowrap; }
|
||||||
|
.spec-row .sd { color: var(--dim); }
|
||||||
|
|
||||||
|
/* swap panel */
|
||||||
|
.swap-side {
|
||||||
|
border: 1px solid var(--line-hi);
|
||||||
|
background: var(--panel2);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.swap-side .top { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.swap-side .side-lbl { color: var(--dim); font-size: 0.8em; width: 40px; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
.swap-side .amt {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 1.5em;
|
||||||
|
width: 100%;
|
||||||
|
caret-color: var(--acc);
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: right;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
.swap-side .amt::placeholder { color: var(--placeholder); }
|
||||||
|
.swap-side .out {
|
||||||
|
font-size: 1.5em;
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.swap-side .meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.swap-mid { display: flex; justify-content: center; margin: -4px 0 4px; }
|
||||||
|
.rate-line { color: var(--dim); font-size: 0.9em; margin: 8px 0 2px; cursor: pointer; }
|
||||||
|
.rate-line:hover { color: var(--fg); }
|
||||||
|
|
||||||
|
.logaction {
|
||||||
|
color: var(--acc);
|
||||||
|
border-color: var(--acc-dim);
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
animation: pulse-border 1.6s ease-in-out 3;
|
||||||
|
}
|
||||||
|
@keyframes pulse-border {
|
||||||
|
50% { box-shadow: 0 0 8px rgba(var(--acc-rgb), 0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zap panel: numbered tx sequence with live states. Capped width so long
|
||||||
|
plan lines can never stretch the POOLS table into horizontal scroll. */
|
||||||
|
.zap { max-width: 900px; }
|
||||||
|
.zap .spec-row .sv { white-space: normal; }
|
||||||
|
.zap-steps { display: flex; flex-direction: column; gap: 2px; margin: 8px 0; }
|
||||||
|
.zstep { display: flex; gap: 8px; align-items: baseline; color: var(--dim); }
|
||||||
|
.zstep .zn { min-width: 16px; text-align: right; color: var(--line-hi); }
|
||||||
|
.zstep .zm { min-width: 12px; }
|
||||||
|
.zstep.done { color: var(--green); }
|
||||||
|
.zstep.run { color: var(--fg); }
|
||||||
|
.zstep.run .zm { animation: blink 1s step-end infinite; }
|
||||||
|
.zstep.fail { color: var(--red); }
|
||||||
|
|
||||||
|
/* positions: per-card value + earning strip */
|
||||||
|
.pos-earn { margin-top: 2px; }
|
||||||
|
.pos-earn .k { color: var(--dim); margin-right: 8px; text-transform: uppercase; font-size: 0.9em; letter-spacing: 0.4px; }
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Address } from 'viem'
|
||||||
|
|
||||||
|
export type TokenInfo = {
|
||||||
|
address: Address
|
||||||
|
symbol: string
|
||||||
|
decimals: number
|
||||||
|
native?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PoolBase = {
|
||||||
|
address: Address
|
||||||
|
token0: Address
|
||||||
|
token1: Address
|
||||||
|
gauge: Address | null
|
||||||
|
gaugeAlive: boolean
|
||||||
|
weight: bigint // Voter vote weight for this pool
|
||||||
|
rewardRate: bigint // UP wei/s while periodFinish in future
|
||||||
|
periodFinish: bigint
|
||||||
|
}
|
||||||
|
|
||||||
|
/** which DEX a pool/position belongs to (POOLS/POSITIONS handle all of them) */
|
||||||
|
export type LpProtocol = 'up33' | 'univ3' | 'univ2'
|
||||||
|
|
||||||
|
export type V2Pool = PoolBase & {
|
||||||
|
kind: 'v2'
|
||||||
|
/** 'up33' = Solidly-style pair (stable flag, gauges); 'univ2' = vanilla Uniswap v2 */
|
||||||
|
protocol: 'up33' | 'univ2'
|
||||||
|
stable: boolean
|
||||||
|
reserve0: bigint
|
||||||
|
reserve1: bigint
|
||||||
|
totalSupply: bigint
|
||||||
|
gaugeTotalSupply: bigint // staked LP total in the gauge
|
||||||
|
feeBps: number // 1 = 0.01%
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClPool = PoolBase & {
|
||||||
|
kind: 'cl'
|
||||||
|
protocol: 'up33' | 'univ3'
|
||||||
|
tickSpacing: number
|
||||||
|
feePpm: number // 1e6 = 100%
|
||||||
|
unstakedFeePpm: number
|
||||||
|
sqrtPriceX96: bigint
|
||||||
|
tick: number
|
||||||
|
liquidity: bigint
|
||||||
|
stakedLiquidity: bigint
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Pool = V2Pool | ClPool
|
||||||
|
|
||||||
|
export type Protocol = {
|
||||||
|
weekly: bigint
|
||||||
|
epochCount: number
|
||||||
|
activePeriod: number
|
||||||
|
totalWeight: bigint
|
||||||
|
capMode: number | null
|
||||||
|
blockNumber: bigint
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PoolsData = {
|
||||||
|
pools: Pool[]
|
||||||
|
tokens: Record<string, TokenInfo> // key: lowercase address
|
||||||
|
protocol: Protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClPosition = {
|
||||||
|
tokenId: bigint
|
||||||
|
pool: ClPool
|
||||||
|
tickLower: number
|
||||||
|
tickUpper: number
|
||||||
|
liquidity: bigint
|
||||||
|
staked: boolean
|
||||||
|
amount0: bigint // current underlying at pool price
|
||||||
|
amount1: bigint
|
||||||
|
fees0: bigint // uncollected fees (wallet positions only)
|
||||||
|
fees1: bigint
|
||||||
|
earned: bigint // pending UP (staked positions only)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2Position = {
|
||||||
|
pool: V2Pool
|
||||||
|
walletLp: bigint
|
||||||
|
stakedLp: bigint
|
||||||
|
earned: bigint // pending UP from gauge
|
||||||
|
claimable0: bigint // unstaked LP fees
|
||||||
|
claimable1: bigint
|
||||||
|
// underlying for wallet+staked LP at current reserves
|
||||||
|
amount0: bigint
|
||||||
|
amount1: bigint
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PositionsData = {
|
||||||
|
cl: ClPosition[]
|
||||||
|
v2: V2Position[]
|
||||||
|
/** metadata for position tokens outside the UP33 pool registry (univ3 pairs) */
|
||||||
|
tokens: Record<string, TokenInfo>
|
||||||
|
}
|
||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly RPC?: string
|
||||||
|
readonly KYBERSWAP_AGGREGATOR_API_BASE_URL?: string
|
||||||
|
readonly KYBERSWAP_CHAIN?: string
|
||||||
|
readonly KYBERSWAP_ROUTER_ADDRESS?: string
|
||||||
|
readonly KYBERSWAP_FEE_BPS?: string
|
||||||
|
readonly KYBERSWAP_FEE_RECEIVER?: string
|
||||||
|
readonly VITE_WALLETCONNECT_PROJECT_ID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "scripts", "indexer"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
// envDir points at the repo root so `.env` (RPC / KYBERSWAP_*) is picked up.
|
||||||
|
// envPrefix exposes exactly those keys to the client bundle.
|
||||||
|
//
|
||||||
|
// SECRET RULE: `RPC` (private key-bearing URL) is for personal/local builds only —
|
||||||
|
// a build meant for public serving must NOT have it set (see README "Chain reads").
|
||||||
|
// A public build reads the chain through same-origin `/rpc` instead; the dev and
|
||||||
|
// preview servers below emulate that reverse proxy so the mode is testable locally.
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const envDir = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const env = loadEnv(mode, envDir, ['RPC'])
|
||||||
|
// local emulation of the production reverse proxies, so the server deployment
|
||||||
|
// mode is fully testable via `RPC="" npm run build && npm run preview`:
|
||||||
|
// - /rpc -> the .env RPC (or RPC_PROXY_TARGET override); key stays in node
|
||||||
|
// - /kyber -> the public kyber aggregator
|
||||||
|
const upstream = (process.env.RPC_PROXY_TARGET ?? env.RPC ?? '').trim()
|
||||||
|
const passthru = (prefix: string, target: string) => ({
|
||||||
|
target,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p: string) => p.replace(new RegExp(`^${prefix}`), ''),
|
||||||
|
})
|
||||||
|
const proxy: Record<string, object> = {
|
||||||
|
'/kyber-setting': passthru('/kyber-setting', 'https://ks-setting.kyberswap.com'),
|
||||||
|
'/kyber': passthru('/kyber', 'https://aggregator-api.kyberswap.com'),
|
||||||
|
'/dexscreener': passthru('/dexscreener', 'https://api.dexscreener.com'),
|
||||||
|
'/goldsky': passthru('/goldsky', 'https://api.goldsky.com'),
|
||||||
|
// local pool indexer (`npm run indexer`) — same-origin /api like the
|
||||||
|
// production nginx route; the frontend falls back to client-side
|
||||||
|
// dexscreener discovery when it isn't running
|
||||||
|
'/api': { target: `http://localhost:${process.env.INDEXER_PORT || 8787}`, changeOrigin: true },
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//.test(upstream)) proxy['/rpc'] = passthru('/rpc', upstream)
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
envDir,
|
||||||
|
envPrefix: ['VITE_', 'RPC', 'KYBERSWAP_'],
|
||||||
|
server: { port: 5173, proxy },
|
||||||
|
preview: { port: 4173, proxy },
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// pin ONLY react into a stable vendor chunk: it is 100% eager and its
|
||||||
|
// hash survives app deploys (cache win, zero size cost). Everything
|
||||||
|
// else — incl. viem/wagmi/rainbowkit — stays on rollup's automatic
|
||||||
|
// split: forcing them together was measured to hoist lazy-only wallet
|
||||||
|
// SDK modules into the eager bundle (957kB -> 1.7MB). Don't.
|
||||||
|
manualChunks(id: string) {
|
||||||
|
if (/\/node_modules\/(react|react-dom|scheduler)\//.test(id)) return 'vendor-react'
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user