From bca538e7e3cbc5c56b8714602933c77a3b39ef38 Mon Sep 17 00:00:00 2001 From: labrinyang <96382703+labrinyang@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:01:52 +0800 Subject: [PATCH] feat: SHEEP CHOICE routing, cross-chain deposits, and any-token ZAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One commit because the pieces do not compile apart: the shared copy, the tab shell and the data layer all changed together, and splitting them further would mean inventing intermediate states that never existed. SHEEP CHOICE — the terminal's own swap. Quotes come from a solver that splits one trade across several pools instead of forcing it down a single path, and returns a ready-to-sign Settler transaction; the UI draws the split leg by leg and scores every venue against one shared fee-free baseline, so the card that says it pays most actually does. The Kyber transaction path is gone — Kyber is read-only USD valuation now, and there is no Kyber calldata to sign. BRIDGE — deposits from other chains over Relay, Across and the native portal, priced side by side and sorted by what actually reaches you. No fee on any of them. In-flight transfers get a countdown and survive a reload. ZAP — add liquidity holding neither side of the pair; whatever needs swapping is done in the same flow, with an optional stake-after step. Uniswap V2 liquidity now shows up under POSITIONS. Swaps in flight are persisted, so a refresh mid-swap no longer loses the transaction. Pair labels copy their token and pool addresses, and jump to DexScreener. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/bridge-smoke.ts | 63 ++ scripts/smoke.ts | 92 +- scripts/uni-browse-smoke.ts | 17 +- src/App.tsx | 16 +- src/abi/index.ts | 25 +- src/components/DexScreenerLink.tsx | 42 + src/components/Header.tsx | 33 +- src/components/HistoryButton.tsx | 38 + src/components/LangControl.tsx | 2 +- src/components/NewsButton.tsx | 63 ++ src/components/PairAddrs.tsx | 187 ++++ src/components/RangeBar.tsx | 64 +- src/components/StakeAfterToggle.tsx | 27 + src/components/TokenSelect.tsx | 62 +- src/components/TxLogPanel.tsx | 2 +- src/components/ZapPanel.tsx | 443 +++++++--- src/components/tabs/BridgeTab.tsx | 729 ++++++++++++++++ src/components/tabs/LabTab.tsx | 44 +- src/components/tabs/PoolsTab.tsx | 327 +++++-- src/components/tabs/PositionsTab.tsx | 797 +++++++++++------ src/components/tabs/SwapTab.tsx | 1198 ++++++++++++++++++-------- src/components/ui.tsx | 15 +- src/config/addresses.ts | 6 +- src/config/bridge.ts | 96 +++ src/config/env.ts | 62 +- src/config/wagmi.ts | 56 +- src/content/changelog.ts | 123 +++ src/hooks/useBalances.ts | 4 +- src/hooks/useBridgeBalance.ts | 29 + src/hooks/useBridgeQuotes.ts | 63 ++ src/hooks/useBridgeTokens.ts | 15 + src/hooks/useEpoch.ts | 15 - src/hooks/useLiveSlot0.ts | 3 +- src/hooks/usePendingBridges.ts | 75 ++ src/hooks/usePendingSwaps.ts | 57 ++ src/hooks/usePoolStats.ts | 20 +- src/hooks/usePools.ts | 4 +- src/hooks/usePositions.ts | 133 ++- src/hooks/useQuotes.ts | 216 ++--- src/hooks/useTokenList.ts | 44 +- src/hooks/useTokenUsd.ts | 31 + src/hooks/useUniPoolStats.ts | 13 +- src/hooks/useUniPools.ts | 6 +- src/hooks/useUpPrice.ts | 27 +- src/i18n/en.ts | 219 +++-- src/i18n/zh.ts | 216 +++-- src/lib/apr.ts | 2 +- src/lib/autostake.ts | 19 + src/lib/bridge/across.ts | 122 +++ src/lib/bridge/bridge.test.ts | 518 +++++++++++ src/lib/bridge/exec.ts | 103 +++ src/lib/bridge/pending.ts | 203 +++++ src/lib/bridge/portal.ts | 105 +++ src/lib/bridge/relay.ts | 125 +++ src/lib/bridge/tokens.ts | 319 +++++++ src/lib/bridge/types.ts | 50 ++ src/lib/clipboard.ts | 40 + src/lib/dexscreener.test.ts | 50 ++ src/lib/dexscreener.ts | 33 + src/lib/directSwap.test.ts | 499 +++++++++++ src/lib/directSwap.ts | 543 ++++++++++++ src/lib/format.test.ts | 66 ++ src/lib/format.ts | 75 +- src/lib/kyber.ts | 139 +-- src/lib/kyberExec.ts | 48 -- src/lib/limit.ts | 2 +- src/lib/news.test.ts | 51 ++ src/lib/news.ts | 25 + src/lib/pendingSwaps.test.ts | 131 +++ src/lib/pendingSwaps.ts | 262 ++++++ src/lib/popover.test.ts | 41 + src/lib/popover.ts | 22 + src/lib/posmetrics.test.ts | 18 + src/lib/posmetrics.ts | 16 + src/lib/refreshTip.test.ts | 11 + src/lib/solver.ts | 153 ++++ src/lib/solverPreflight.test.ts | 35 + src/lib/solverPreflight.ts | 23 + src/lib/solverRefresh.test.ts | 119 +++ src/lib/solverRefresh.ts | 70 ++ src/lib/solverResponse.test.ts | 22 + src/lib/solverResponse.ts | 16 + src/lib/stake.ts | 97 +++ src/lib/swapExec.ts | 218 +++++ src/lib/swapGate.test.ts | 135 +++ src/lib/swapGate.ts | 82 ++ src/lib/swapSubmissions.test.ts | 31 + src/lib/swapSubmissions.ts | 47 + src/lib/tx.test.ts | 80 ++ src/lib/tx.ts | 132 ++- src/lib/txlog.ts | 3 + src/lib/uniBrowse.ts | 5 +- src/lib/v2Fees.test.ts | 40 + src/lib/v2Fees.ts | 21 + src/lib/zap.ts | 549 ++++++++---- src/lib/zapMath.test.ts | 63 ++ src/lib/zapMath.ts | 47 + src/main.tsx | 2 +- src/styles.css | 849 +++++++++++++++++- src/vite-env.d.ts | 2 - 100 files changed, 10659 insertions(+), 1639 deletions(-) create mode 100644 scripts/bridge-smoke.ts create mode 100644 src/components/DexScreenerLink.tsx create mode 100644 src/components/HistoryButton.tsx create mode 100644 src/components/NewsButton.tsx create mode 100644 src/components/PairAddrs.tsx create mode 100644 src/components/StakeAfterToggle.tsx create mode 100644 src/components/tabs/BridgeTab.tsx create mode 100644 src/config/bridge.ts create mode 100644 src/content/changelog.ts create mode 100644 src/hooks/useBridgeBalance.ts create mode 100644 src/hooks/useBridgeQuotes.ts create mode 100644 src/hooks/useBridgeTokens.ts delete mode 100644 src/hooks/useEpoch.ts create mode 100644 src/hooks/usePendingBridges.ts create mode 100644 src/hooks/usePendingSwaps.ts create mode 100644 src/hooks/useTokenUsd.ts create mode 100644 src/lib/autostake.ts create mode 100644 src/lib/bridge/across.ts create mode 100644 src/lib/bridge/bridge.test.ts create mode 100644 src/lib/bridge/exec.ts create mode 100644 src/lib/bridge/pending.ts create mode 100644 src/lib/bridge/portal.ts create mode 100644 src/lib/bridge/relay.ts create mode 100644 src/lib/bridge/tokens.ts create mode 100644 src/lib/bridge/types.ts create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/dexscreener.test.ts create mode 100644 src/lib/dexscreener.ts create mode 100644 src/lib/directSwap.test.ts create mode 100644 src/lib/directSwap.ts create mode 100644 src/lib/format.test.ts delete mode 100644 src/lib/kyberExec.ts create mode 100644 src/lib/news.test.ts create mode 100644 src/lib/news.ts create mode 100644 src/lib/pendingSwaps.test.ts create mode 100644 src/lib/pendingSwaps.ts create mode 100644 src/lib/popover.test.ts create mode 100644 src/lib/popover.ts create mode 100644 src/lib/posmetrics.test.ts create mode 100644 src/lib/refreshTip.test.ts create mode 100644 src/lib/solver.ts create mode 100644 src/lib/solverPreflight.test.ts create mode 100644 src/lib/solverPreflight.ts create mode 100644 src/lib/solverRefresh.test.ts create mode 100644 src/lib/solverRefresh.ts create mode 100644 src/lib/solverResponse.test.ts create mode 100644 src/lib/solverResponse.ts create mode 100644 src/lib/stake.ts create mode 100644 src/lib/swapExec.ts create mode 100644 src/lib/swapGate.test.ts create mode 100644 src/lib/swapGate.ts create mode 100644 src/lib/swapSubmissions.test.ts create mode 100644 src/lib/swapSubmissions.ts create mode 100644 src/lib/tx.test.ts create mode 100644 src/lib/v2Fees.test.ts create mode 100644 src/lib/v2Fees.ts create mode 100644 src/lib/zapMath.test.ts create mode 100644 src/lib/zapMath.ts diff --git a/scripts/bridge-smoke.ts b/scripts/bridge-smoke.ts new file mode 100644 index 0000000..78fc950 --- /dev/null +++ b/scripts/bridge-smoke.ts @@ -0,0 +1,63 @@ +// Live smoke of the bridge layer: token DISCOVERY per remote (relay/across +// support surfaces, same-token only), then every discovered route × direction +// quoted through each supporting engine, printed as the normalized BridgeQuote +// the UI consumes. Read-only (quotes only, placeholder payer) — safe any time. +// npm run smoke:bridge +import { formatUnits, parseUnits } from 'viem' +import { REMOTE_CHAINS, resolveIntent, type BridgeDir } from '../src/config/bridge' +import { quoteAcross } from '../src/lib/bridge/across' +import { quotePortal } from '../src/lib/bridge/portal' +import { quoteRelay } from '../src/lib/bridge/relay' +import { fetchBridgeTokens, toTokenOption } from '../src/lib/bridge/tokens' +import type { BridgeFee, BridgeProviderId, BridgeQuote } from '../src/lib/bridge/types' + +const FEE: BridgeFee = { bps: 0, receiver: '0x1111111111111111111111111111111111111111' } +const REMOTES = [ + REMOTE_CHAINS.find((r) => r.label === 'ETHEREUM')!, + REMOTE_CHAINS.find((r) => r.label === 'BASE')!, +] + +let failures = 0 +for (const remote of REMOTES) { + const supports = await fetchBridgeTokens(remote) + console.log(`\n== ${remote.label} — discovered same-token routes ==`) + for (const s of supports) { + console.log( + ` ${s.symbol.padEnd(5)} dec=${s.decimals} rh=${s.robinhoodToken.slice(0, 10)} remote=${s.remoteToken.slice(0, 10)} ` + + `relay=${s.relay} across=${s.across} portal=${s.portal}`, + ) + } + for (const s of supports) { + for (const dir of ['in', 'out'] as BridgeDir[]) { + const token = toTokenOption(s, dir) + if (token.providers.length === 0) continue + const amount = token.symbol === 'USDG' ? parseUnits('100', token.decimals) : parseUnits('0.05', token.decimals) + const leg = resolveIntent({ dir, token, remote, amount }) + const inNum = Number(formatUnits(amount, leg.inputDecimals)) + const runners: Partial Promise>> = { + portal: () => Promise.resolve(quotePortal(leg, amount)), + relay: () => quoteRelay(leg, amount, FEE, null), + across: () => quoteAcross(leg, amount, FEE, null), + } + for (const provider of token.providers) { + const t0 = Date.now() + try { + const q = await runners[provider]!() + const outNum = Number(formatUnits(q.outputAmount, leg.outputDecimals)) + const impact = ((outNum / inNum - 1) * 100).toFixed(2) + console.log( + ` ${token.symbol.padEnd(5)} ${dir.padEnd(3)} ${provider.padEnd(6)} out=${outNum.toFixed(6)} ${leg.outputSymbol.padEnd(4)} ` + + `impact=${impact}% eta=${q.etaSec}s steps=${q.steps.map((st) => st.kind).join('+')} ` + + `(${Date.now() - t0}ms)`, + ) + } catch (e) { + failures++ + console.log( + ` ${token.symbol.padEnd(5)} ${dir.padEnd(3)} ${provider.padEnd(6)} FAILED: ${(e as Error).message.slice(0, 90)} (${Date.now() - t0}ms)`, + ) + } + } + } + } +} +console.log(`\n${failures === 0 ? 'ALL ROUTES QUOTED' : `${failures} route quote(s) failed`}`) diff --git a/scripts/smoke.ts b/scripts/smoke.ts index c21c3e3..5486b6f 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -1,5 +1,5 @@ // Live-chain smoke test for the read layer: TickMath constants, ABI selectors, -// pool discovery, quoter-vs-kyber sanity. Run: npm run smoke +// pool discovery and direct Uniswap/UP33 quote sanity. Run: npm run smoke // Never prints the RPC URL. import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -20,6 +20,8 @@ import { getSqrtRatioAtTick, sqrtPriceToPrice, } from '../src/lib/clmath' +import { directRouteLabel, netAfterFee, quoteDirectCandidates } from '../src/lib/directSwap' +import type { ClPool } from '../src/types' let pass = 0 let fail = 0 @@ -29,21 +31,9 @@ function check(name: string, cond: boolean, detail = '') { 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 envText = readFileSync(fileURLToPath(new URL('../../.env', import.meta.url)), 'utf8') +const rpc = + envText.match(/^\s*RPC\s*=\s*(\S+)\s*$/m)?.[1] ?? 'https://rpc.mainnet.chain.robinhood.com' const chain = defineChain({ id: 4663, @@ -114,6 +104,7 @@ async function main() { { abi: clPoolAbi, address: p, functionName: 'token0' }, { abi: clPoolAbi, address: p, functionName: 'token1' }, { abi: clPoolAbi, address: p, functionName: 'tickSpacing' }, + { abi: clPoolAbi, address: p, functionName: 'fee' }, { abi: clPoolAbi, address: p, functionName: 'liquidity' }, { abi: clPoolAbi, address: p, functionName: 'gauge' }, ]) as never, @@ -127,18 +118,20 @@ async function main() { t0: Address t1: Address ts: number + fee: number liq: bigint gauge?: Address }[] = [] clAddrs.forEach((p, i) => { - const s0 = ok(slotRes[i * 6]) - const t0 = ok
(slotRes[i * 6 + 1]) - const t1 = ok
(slotRes[i * 6 + 2]) - const ts = ok(slotRes[i * 6 + 3]) - const liq = ok(slotRes[i * 6 + 4]) ?? 0n - const gauge = ok
(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 }) + const s0 = ok(slotRes[i * 7]) + const t0 = ok
(slotRes[i * 7 + 1]) + const t1 = ok
(slotRes[i * 7 + 2]) + const ts = ok(slotRes[i * 7 + 3]) + const fee = ok(slotRes[i * 7 + 4]) + const liq = ok(slotRes[i * 7 + 5]) ?? 0n + const gauge = ok
(slotRes[i * 7 + 6]) + if (!s0 || !t0 || !t1 || ts === undefined || fee === undefined) return + clPools.push({ addr: p, sqrtP: s0[0], tick: s0[1], t0, t1, ts, fee, liq, gauge }) if (s0[0] === 0n) return const lo = getSqrtRatioAtTick(s0[1]) const hi = getSqrtRatioAtTick(s0[1] + 1) @@ -147,7 +140,7 @@ async function main() { }) 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 + // 6. WETH/UP: raw UP33 quoter plus the product's direct comparison module const wethUp = clPools .filter( (p) => @@ -194,22 +187,41 @@ async function main() { 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)) + const smokePool: ClPool = { + kind: 'cl', + protocol: 'up33', + address: wethUp.addr, + token0: wethUp.t0, + token1: wethUp.t1, + tickSpacing: wethUp.ts, + feePpm: wethUp.fee, + unstakedFeePpm: 0, + sqrtPriceX96: wethUp.sqrtP, + tick: wethUp.tick, + liquidity: wethUp.liq, + stakedLiquidity: 0n, + gauge: null, + gaugeAlive: false, + weight: 0n, + rewardRate: 0n, + periodFinish: 0n, } + const direct = await quoteDirectCandidates(pc, [smokePool], ADDR.WETH, ADDR.UP, oneWeth, 9) + check( + 'direct comparison quotes Uniswap and UP33', + direct.status.uniswap === 'quoted' && direct.status.up33 === 'quoted', + `${direct.status.uniswap}/${direct.status.up33}`, + ) + check( + 'direct UP33 quote is net of the exact 9 bps fee', + !!quoterOut && direct.byProtocol.up33?.amountOut === netAfterFee(quoterOut, 9), + direct.byProtocol.up33?.amountOut.toString() ?? 'no quote', + ) + check( + 'direct comparison selects a concrete route', + direct.best !== null, + direct.best ? directRouteLabel(direct.best.route) : 'none', + ) } // 7. v2 gauge standard selectors respond (gauge instances are unverified on Blockscout) diff --git a/scripts/uni-browse-smoke.ts b/scripts/uni-browse-smoke.ts index c829d02..6bcdfbb 100644 --- a/scripts/uni-browse-smoke.ts +++ b/scripts/uni-browse-smoke.ts @@ -8,21 +8,8 @@ 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 envText = readFileSync(new URL('../../.env', import.meta.url), 'utf8') +const rpc = envText.match(/^\s*RPC\s*=\s*(\S+)\s*$/m)?.[1] ?? 'https://rpc.mainnet.chain.robinhood.com' const chain = defineChain({ id: 4663, name: 'Robinhood Chain', diff --git a/src/App.tsx b/src/App.tsx index 2526b39..c492795 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,11 +9,10 @@ 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 { BridgeTab } from './components/tabs/BridgeTab' import { LabTab } from './components/tabs/LabTab' import { PoolsTab } from './components/tabs/PoolsTab' import { PositionsTab } from './components/tabs/PositionsTab' @@ -45,12 +44,12 @@ export default function App() { ) } -const KEYS: Record = { '1': 'pools', '2': 'positions', '3': 'swap' } +const KEYS: Record = { '1': 'pools', '2': 'positions', '3': 'swap', '5': 'bridge' } 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 + return (['pools', 'positions', 'swap', 'bridge'] as const).includes(h as TabId) ? (h as TabId) : null } function Shell() { @@ -97,7 +96,7 @@ function Shell() {
- {isConnected && chainId !== CHAIN_ID && ( + {isConnected && chainId !== CHAIN_ID && tab !== 'bridge' && (
{t('app.wrongNetwork')} switchChain({ chainId: CHAIN_ID })}>{t('app.switch')} @@ -106,14 +105,13 @@ function Shell() { {tab === 'pools' && (location.hash === '#lab' ? : )} {tab === 'positions' && } {tab === 'swap' && } + {tab === 'bridge' && }
-
- {t('app.tagline')} - {t('app.keys')} + {t('app.tagline')} + {t('app.keys')} - {t('app.blockscout')} diff --git a/src/abi/index.ts b/src/abi/index.ts index ea459e7..8080c66 100644 --- a/src/abi/index.ts +++ b/src/abi/index.ts @@ -1,8 +1,8 @@ import { parseAbi } from 'viem' export { erc20Abi } from 'viem' -// Signatures below were extracted verbatim from Blockscout-verified ABIs. -// Only what the app calls is included. +// Signatures below were extracted verbatim from Blockscout-verified ABIs +// (see docs/up33-contract-map.md §4). Only what the app calls is included. export const wethAbi = parseAbi([ 'function deposit() payable', @@ -155,16 +155,32 @@ export const uniV2FactoryAbi = parseAbi([ export const uniV2PairAbi = parseAbi([ 'function token0() view returns (address)', 'function token1() view returns (address)', + 'function factory() 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 getAmountsOut(uint256 amountIn, address[] path) view returns (uint256[] amounts)', '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)', ]) +export const uniV3QuoterAbi = parseAbi([ + 'function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96) params) view returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)', +]) + +export const uniSwapRouterAbi = parseAbi([ + 'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMinimum, address[] path, address recipient) payable returns (uint256 amountOut)', + 'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)', + 'function multicall(uint256 deadline, bytes[] data) payable returns (bytes[] results)', + 'function sweepTokenWithFee(address token, uint256 amountMinimum, address recipient, uint256 feeBips, address feeRecipient) payable', + 'function unwrapWETH9WithFee(uint256 amountMinimum, address recipient, uint256 feeBips, address feeRecipient) payable', + 'function sweepToken(address token, uint256 amountMinimum, address recipient) payable', + 'function unwrapWETH9(uint256 amountMinimum, address recipient) payable', +]) + // 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([ @@ -173,4 +189,9 @@ export const quoterAbi = parseAbi([ 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)', + 'function multicall(bytes[] data) payable returns (bytes[] results)', + 'function sweepTokenWithFee(address token, uint256 amountMinimum, address recipient, uint256 feeBips, address feeRecipient) payable', + 'function unwrapWETH9WithFee(uint256 amountMinimum, address recipient, uint256 feeBips, address feeRecipient) payable', + 'function sweepToken(address token, uint256 amountMinimum, address recipient) payable', + 'function unwrapWETH9(uint256 amountMinimum, address recipient) payable', ]) diff --git a/src/components/DexScreenerLink.tsx b/src/components/DexScreenerLink.tsx new file mode 100644 index 0000000..345ca72 --- /dev/null +++ b/src/components/DexScreenerLink.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import { dsPairUrl } from '../lib/dexscreener' + +// DexScreener's own mark, from their favicon (their CDN refuses plain fetches +// and ships no public SVG), re-encoded lossless to 686 B and inlined so the +// page stays self-contained under CSP — same treatment as ProtoBadge's UP icon. +// The art is a white glyph on a black rounded square: on this terminal's near- +// black panels the square disappears and only the glyph reads. +const DS_ICON = + 'data:image/webp;base64,UklGRqYCAABXRUJQVlA4TJoCAAAvL8ALEHfBoG0kR8m17zvPn+zRYNi2jSPp2vf491/2GLaNpCjH/Om/1idUkW1jNaH/lyYk8Bp4DW7s/ee6xoI8IZfeDyigFRDAf4HsTUMYrQGIssfI5zznV0CQKJEiUSIIFFVSVEmRKJGiSgoIkgsEiir9vyBILhAkUkCCtW1RW31tv86Txj/526tXbOlA0p+w//UpQrKCiP5PQEopvb5c8wxvH67SYvPIs31sUkrNLc/4tknpkeXqQgRi+NiV8TFdsFgDVu7fi3j5UNJ9o9BLyfNdgRiKTQqumZUNSTFUNCHZ7TJrD0IxVDWh/LDiYP0BlYPaUIMBJwys2lo9kzoUq2XCuiPF6phwrKJwlH2Ng9BBa4yAI6eyz44OGGtMQCDpSkaSAfiq8Qt8UYVia0wp7/SVwmw0ZTsB8ecHwNRRzc1CpT0VcOTupyPlQ9lNgDDUg9IBJiT/9yS3BjgKAKvxB2C/oTin5AR8km9uVHZWC3NTznsA6DlXw7zCbgGw8LPjfranhPCHZSl7ywCgYFEYke/LfC6O1KV3uhVTmWWCsPNLn0IJmWORYjmQrSFrHemXoCUewwK8cBuX4pZdwOIIXyBRdQm2oy7949awrHrYrBuEUwZH4TRzlIjsRNmtI2k52KaNgHU05I8s77HWcQQGjlirZQYbcsdNG42t5XpDKOphwpiBo/PskT1SDGOBWOhIP9sDOLIVGoA486T4o6wLA0kqAK8AoKQCwNsEQEly8KvajosBJvwBEEgPwLONMJ5Q4ck2AsdNC8CEdBhOwSAkFcDYAxhItsaTdpxPQAjAxHlXcl2QDQBgrHrzVEcMMKnzcFmH24gt616lpzocHes+pdTc1ql926SUmqfzeWrS4sX93TlcP1+mlBI=' + +/** + * The pair's DexScreener chart, one click away — sits right of the protocol + * mark, wherever a pair is named. + * + * A plain anchor by design: the browser does the navigating, so there is no + * runtime-built URL string for untrusted data to land in. `rel` names both + * tokens even though `noreferrer` already implies `noopener` — the promise + * that the opened tab gets no handle back into this one (the tab that has the + * user's wallet connected) shouldn't quietly rest on that implication. + * + * Renders nothing when the pool address doesn't validate: see lib/dexscreener. + */ +export function DexScreenerLink({ pool }: { pool: string }) { + const { t } = useTranslation() + const href = dsPairUrl(pool) + if (!href) return null + return ( + e.stopPropagation()} + > + + DS↗ + + ) +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fb5a40a..1526f52 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,26 +1,30 @@ 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' +import { HistoryButton } from './HistoryButton' +import { LangControl } from './LangControl' +import { NewsButton } from './NewsButton' -export type TabId = 'pools' | 'positions' | 'swap' +export type TabId = 'pools' | 'positions' | 'swap' | 'bridge' const TABS = [ { id: 'pools', labelKey: 'hdr.pools', key: '1' }, { id: 'positions', labelKey: 'hdr.positions', key: '2' }, { id: 'swap', labelKey: 'hdr.swap', key: '3' }, + { id: 'bridge', labelKey: 'hdr.bridge', key: '5' }, ] 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 (
+ {/* the wordmark contracts to "LP▮" on a phone — at full length the utility + cluster no longer fits one row, and CONNECT wraps to a second */} - LPTERMINAL + LP + TERMINAL
{TABS.map((tb) => ( @@ -34,15 +38,14 @@ export function Header(props: { tab: TabId; onTab: (t: TabId) => void }) { ))}
- - {t('hdr.epoch')} {p ? p.epochCount : '…'} · {t('hdr.flip')} {fmtDur(epoch.secsLeft)} - {p ? ( - <> - {' '} - · {t('hdr.blk')} {p.blockNumber.toString()} - - ) : null} - + {p && ( + + {t('hdr.blk')} {p.blockNumber.toString()} + + )} + + + {({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => { if (!mounted) return @@ -61,7 +64,7 @@ export function Header(props: { tab: TabId; onTab: (t: TabId) => void }) { return ( ) }} diff --git a/src/components/HistoryButton.tsx b/src/components/HistoryButton.tsx new file mode 100644 index 0000000..3a33913 --- /dev/null +++ b/src/components/HistoryButton.tsx @@ -0,0 +1,38 @@ +import { useState, useSyncExternalStore } from 'react' +import { useTranslation } from 'react-i18next' +import { txlog } from '../lib/txlog' +import { TxLogPanel } from './TxLogPanel' + +// The terminal activity log lives here now — a header button, not a footer +// panel. Collapsed by default; the button still surfaces live tx state (count + +// a pulse while something is pending) so feedback isn't lost when it's closed. +export function HistoryButton() { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const lines = useSyncExternalStore(txlog.subscribe, txlog.get) + const pending = lines.some((l) => l.kind === 'pending') + // surface the latest failure even while collapsed — the always-on panel that + // used to show reverts inline is gone, so the button has to carry that signal + const failed = !pending && lines[lines.length - 1]?.kind === 'err' + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ +
+ + )} +
+ ) +} diff --git a/src/components/LangControl.tsx b/src/components/LangControl.tsx index 073afbf..52cd1fc 100644 --- a/src/components/LangControl.tsx +++ b/src/components/LangControl.tsx @@ -3,7 +3,7 @@ import { currentLang, setLang, type Lang } from '../i18n' const LABELS: Record = { en: 'EN', zh: '中文' } -/** footer language switcher — instant, persisted, wallet modal follows */ +/** header language switcher — instant, persisted, wallet modal follows */ export function LangControl() { const { t, i18n } = useTranslation() void i18n.language // subscribe: re-render on language change diff --git a/src/components/NewsButton.tsx b/src/components/NewsButton.tsx new file mode 100644 index 0000000..ddfa35d --- /dev/null +++ b/src/components/NewsButton.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CHANGELOG } from '../content/changelog' +import { currentLang } from '../i18n' +import { freshCount, isFresh } from '../lib/news' + +// WHAT'S NEW — same shell as the activity log next door: a header button with a +// popover. The red dot is purely time-based (lib/news.ts), so there is no read +// state to store and nothing to mark: an entry stops being new by ageing out. +export function NewsButton() { + const { t, i18n } = useTranslation() + void i18n.language // subscribe: entry text follows the language switch + const [open, setOpen] = useState(false) + const lang = currentLang() + const now = Date.now() // re-read per render; the header re-renders on every block + const fresh = freshCount(now) + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+
+ {t('news.title')} + +
+
+ {CHANGELOG.length === 0 &&
{t('news.empty')}
} + {CHANGELOG.map((e) => ( +
+
+ + {e.date.slice(5)} + + {e.tag} + {e.title[lang]} + {isFresh(e, now) && } +
+ {e.items.map((it) => ( +
+ {it[lang]} +
+ ))} +
+ ))} +
+
+ + )} +
+ ) +} diff --git a/src/components/PairAddrs.tsx b/src/components/PairAddrs.tsx new file mode 100644 index 0000000..595adbc --- /dev/null +++ b/src/components/PairAddrs.tsx @@ -0,0 +1,187 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { EXPLORER } from '../config/addresses' +import { copyText } from '../lib/clipboard' +import { shortAddr } from '../lib/format' +import { popoverTop } from '../lib/popover' + +const POP_W = 320 +const GAP = 8 +const DROP = 6 // gap between the trigger and the card it opens + +type Entry = { k: string; addr: string; token: boolean } + +/** `from` is the trigger's box, kept so placement can flip the card above it */ +type At = { top: number; left: number; from: { top: number; bottom: number }; placed: boolean } + +/** + * The pair label, as a button that opens the three addresses behind it: both + * tokens and the pool itself. + * + * Every address here exists to be pasted somewhere else — a block explorer, a + * wallet's import-token box, a script — so the popover is a copy surface first + * and a reference second, and each row copies the FULL address even though it + * only has room to show a shortened one. + * + * Drop-in anywhere a pair is named: the trigger stops its own click, so the + * clickable rows and card headers it sits inside don't also fire. + */ +export function PairAddrs(props: { + sym0: string + sym1: string + token0: string + token1: string + pool: string + /** class for the trigger, so callers keep their own type scale (b, card-title, …) */ + className?: string +}) { + const { t } = useTranslation() + const [at, setAt] = useState(null) + // both hold the address they apply to, not a flag: three rows share this + // state, and a bare boolean made one row's failure light up all three + const [copied, setCopied] = useState(null) + const [failed, setFailed] = useState(null) + const timer = useRef>() + const pop = useRef(null) + + useEffect(() => () => clearTimeout(timer.current), []) + + // The card is fixed-positioned and ANY scroll closes it, so one that opens + // past the fold cannot be reached — scrolling toward it dismisses it. Its + // height depends on its content (localized labels, however many rows), so + // measure the real box once it is up and place from that. useLayoutEffect, + // not useEffect: the correction lands before the browser paints. `placed` + // makes this run exactly once per opening, so it cannot oscillate. + useLayoutEffect(() => { + if (!at || at.placed) return + const h = pop.current?.offsetHeight ?? 0 + setAt({ ...at, top: popoverTop(at.from, h, window.innerHeight, GAP, DROP), placed: true }) + }, [at]) + + // A row-anchored popover has nothing to hold onto once the row moves, and the + // pools table scrolls inside its own box — so any scroll closes it rather than + // leaving a card floating over an unrelated row. Escape closes it too. + useEffect(() => { + if (!at) return + const close = () => setAt(null) + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && close() + window.addEventListener('scroll', close, true) + window.addEventListener('resize', close) + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('scroll', close, true) + window.removeEventListener('resize', close) + window.removeEventListener('keydown', onKey) + } + }, [at]) + + const open = (e: React.MouseEvent) => { + e.stopPropagation() // the row/card behind this is its own toggle + if (at) { + setAt(null) + return + } + const r = e.currentTarget.getBoundingClientRect() + // clamp into the viewport: this is position:fixed, and a card hanging off + // the right edge would put the horizontal scrollbar back on a phone + const w = Math.min(POP_W, window.innerWidth - 2 * GAP) + // `top` here is provisional — the layout effect above replaces it with a + // measured one before this ever paints + setAt({ + top: r.bottom + DROP, + left: Math.max(GAP, Math.min(r.left, window.innerWidth - w - GAP)), + from: { top: r.top, bottom: r.bottom }, + placed: false, + }) + } + + const copy = async (addr: string) => { + const ok = await copyText(addr) + clearTimeout(timer.current) + setCopied(ok ? addr : null) + setFailed(ok ? null : addr) + timer.current = setTimeout(() => { + setCopied(null) + setFailed(null) + }, 1400) + } + + const entries: Entry[] = [ + { k: props.sym0, addr: props.token0, token: true }, + { k: props.sym1, addr: props.token1, token: true }, + { k: t('pools.addrPool'), addr: props.pool, token: false }, + ] + + const status = (addr: string) => + copied === addr ? ( + {t('pools.addrCopied')} + ) : failed === addr ? ( + {t('pools.addrFailed')} + ) : ( + + ) + + return ( + <> + + {/* Portaled to : the trigger sits inside a table cell that mobile + gives `overflow: hidden` and `white-space: nowrap`. The portal escapes + both the clip and the inherited typography — React still bubbles events + through the COMPONENT tree though, so the stopPropagation below stays + load-bearing for the row/card toggles behind this. */} + {at && + createPortal( + <> +
{ + e.stopPropagation() + setAt(null) + }} + /> +
e.stopPropagation()} + > +
+ + {props.sym0}/{props.sym1} + + {t('pools.addrHint')} +
+ {entries.map((e) => ( +
+ {e.k} + + + ↗ + +
+ ))} +
+ , + document.body, + )} + + ) +} diff --git a/src/components/RangeBar.tsx b/src/components/RangeBar.tsx index 70c2f69..35c5696 100644 --- a/src/components/RangeBar.tsx +++ b/src/components/RangeBar.tsx @@ -1,9 +1,16 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { sqrtPriceToPrice, tickToPrice } from '../lib/clmath' +import { MAX_TICK, MIN_TICK, sqrtPriceToPrice, tickToPrice } from '../lib/clmath' import { fmtNum } from '../lib/format' import { Flash } from './Flash' +// a bound within this many ticks of the tick-space extreme is a FULL-RANGE +// edge — its price is astronomical/zero, so render ∞/0 instead of the number. +// The widest tick spacing in use is ~200, so 1000 clears every aligned extreme +// without ever catching a real band (real bands sit near the current tick). +const UNBOUNDED_MARGIN = 1000 +const INF = '∞' + /** * The LP range bar: * [lower price] ▕░░▓▓▓▓▓┃▓▓░░▏ [upper price] @@ -60,6 +67,18 @@ export function RangeBar(props: { const nearEdge = inRange && (posPct < 12 || posPct > 88) const tone = order ? '' : !inRange ? 'red' : nearEdge ? 'amber' : '' + // full-range edges: detect in tick space, map to display space (dLower → 0, + // dUpper → ∞; the two swap when flipped). A full-range side has no finite + // bound and no single-token "out here" state, so its number/label is dropped. + const upperUnbounded = tickUpper >= MAX_TICK - UNBOUNDED_MARGIN + const lowerUnbounded = tickLower <= MIN_TICK + UNBOUNDED_MARGIN + const leftUnbounded = flipped ? upperUnbounded : lowerUnbounded + const rightUnbounded = flipped ? lowerUnbounded : upperUnbounded + // which single token you hold once price leaves the band (display space): + // the low side (left) is 100% base, the high side (right) is 100% quote + const heldLow = !order && !inRange && dCur < dLower + const heldHigh = !order && !inRange && dCur > dUpper + // 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. @@ -92,6 +111,10 @@ export function RangeBar(props: { const toRight = (dUpper / dCur - 1) * 100 // band half-width, geometric: ±x% around mid const bandPct = (Math.sqrt(dUpper / dLower) - 1) * 100 + // ∞ on an unbounded (full-range) side rather than an astronomical number + const leftMove = leftUnbounded ? INF : `${toLeft >= 0 ? '+' : ''}${fmtNum(toLeft, 3)}%` + const rightMove = rightUnbounded ? INF : `${toRight >= 0 ? '+' : ''}${fmtNum(toRight, 3)}%` + const bandStr = leftUnbounded || rightUnbounded ? INF : fmtNum(bandPct, 3) let statusText: string let statusTone: string @@ -108,7 +131,7 @@ export function RangeBar(props: { statusTone = 'cyan' } } else if (inRange) { - statusText = t('rbar.inRange', { pct: posPct.toFixed(1), band: fmtNum(bandPct, 3) }) + statusText = t('rbar.inRange') statusTone = nearEdge ? 'amber' : 'green' } else if (dCur < dLower) { statusText = t('rbar.outRise', { pct: fmtNum(toLeft, 3) }) @@ -120,8 +143,22 @@ export function RangeBar(props: { return (
+ {!order && (!leftUnbounded || !rightUnbounded) && ( +
+ {leftUnbounded ? ( + + ) : ( + ↓ {t('rbar.allOf', { sym: base })} + )} + {rightUnbounded ? ( + + ) : ( + {t('rbar.allOf', { sym: quote })} ↑ + )} +
+ )}
- {fmtNum(dLower)} + {leftUnbounded ? '0' : fmtNum(dLower)}
- {fmtNum(dUpper)} + {rightUnbounded ? INF : fmtNum(dUpper)}
- - {toLeft >= 0 ? '+' : ''} - {fmtNum(toLeft, 3)}% {t('rbar.toLow')} + + {leftMove} {t('rbar.toLow')} px{' '} @@ -161,14 +197,20 @@ export function RangeBar(props: { ⇄ - dUpper ? 'red' : 'dim'}> - {t('rbar.fromHigh')} {toRight >= 0 ? '+' : ''} - {fmtNum(toRight, 3)}% + + {t('rbar.fromHigh')} {rightMove}
- {statusText} + + {statusText} +
diff --git a/src/components/StakeAfterToggle.tsx b/src/components/StakeAfterToggle.tsx new file mode 100644 index 0000000..118ef0f --- /dev/null +++ b/src/components/StakeAfterToggle.tsx @@ -0,0 +1,27 @@ +import { useSyncExternalStore } from 'react' +import { useTranslation } from 'react-i18next' +import { autostake } from '../lib/autostake' + +// "Stake after adding" — shown only for stakeable (gauge-live) targets. The +// checked/unchecked state is remembered (localStorage) and shared across every +// add surface, so toggling here sticks everywhere. Disabled while its flow is +// running: the preference is captured at click time, so a mid-run change could +// only desync the progress display from what actually executes. +export function StakeAfterToggle(props: { disabled?: boolean }) { + const { t } = useTranslation() + const on = useSyncExternalStore(autostake.subscribe, autostake.get) + return ( + + ) +} diff --git a/src/components/TokenSelect.tsx b/src/components/TokenSelect.tsx index d0c67b1..40cb49e 100644 --- a/src/components/TokenSelect.tsx +++ b/src/components/TokenSelect.tsx @@ -1,7 +1,9 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { Address } from 'viem' -import { NATIVE } from '../lib/kyber' +import { useQuery } from '@tanstack/react-query' +import { usePublicClient } from 'wagmi' +import { erc20Abi, getAddress, type Address } from 'viem' +import { CHAIN_ID, NATIVE } from '../config/addresses' import { shortAddr } from '../lib/format' import type { TokenInfo } from '../types' @@ -10,8 +12,11 @@ export function TokenSelect(props: { value: TokenInfo exclude?: Address onChange: (t: TokenInfo) => void + /** fixed button text (e.g. "other") — default shows the selected symbol */ + label?: string }) { const { t } = useTranslation() + const client = usePublicClient({ chainId: CHAIN_ID }) const [open, setOpen] = useState(false) const [q, setQ] = useState('') const filtered = useMemo(() => { @@ -24,10 +29,41 @@ export function TokenSelect(props: { return l.slice(0, 80) }, [props.list, props.exclude, q]) + // a pasted address that no listed token matches → read its ERC-20 identity + // from the chain, so ANY token is selectable, not just discovered ones + const unlisted = useMemo(() => { + const s = q.trim() + if (filtered.length > 0 || !/^0x[0-9a-fA-F]{40}$/.test(s)) return null + try { + return getAddress(s) + } catch { + return null + } + }, [q, filtered.length]) + const meta = useQuery({ + queryKey: ['tokenMeta', unlisted], + enabled: !!unlisted && !!client && open, + staleTime: Infinity, + retry: false, + queryFn: async (): Promise => { + const [symbol, decimals] = await Promise.all([ + client!.readContract({ abi: erc20Abi, address: unlisted!, functionName: 'symbol' }), + client!.readContract({ abi: erc20Abi, address: unlisted!, functionName: 'decimals' }), + ]) + return { address: unlisted!, symbol, decimals } + }, + }) + + const pick = (tok: TokenInfo) => { + props.onChange(tok) + setOpen(false) + setQ('') + } + return (
{open && ( <> @@ -44,22 +80,22 @@ export function TokenSelect(props: { />
{filtered.map((tok) => ( -
{ - props.onChange(tok) - setOpen(false) - setQ('') - }} - > +
pick(tok)}> {tok.symbol} {tok.native && {t('common.gasToken')}} {tok.native ? NATIVE.slice(0, 8) : shortAddr(tok.address)}
))} - {filtered.length === 0 &&
{t('common.noMatch')}
} + {unlisted && meta.data && ( +
pick(meta.data)}> + {meta.data.symbol} + {shortAddr(meta.data.address)} +
+ )} + {unlisted && meta.isLoading &&
{t('common.tokenResolving')}
} + {unlisted && meta.isError &&
{t('common.tokenNotErc20')}
} + {filtered.length === 0 && !unlisted &&
{t('common.noMatch')}
}
)} diff --git a/src/components/TxLogPanel.tsx b/src/components/TxLogPanel.tsx index 43292ec..07d7696 100644 --- a/src/components/TxLogPanel.tsx +++ b/src/components/TxLogPanel.tsx @@ -44,7 +44,7 @@ export function TxLogPanel() { {glyph(l)} {l.text} {l.hash && ( - + tx↗ )} diff --git a/src/components/ZapPanel.tsx b/src/components/ZapPanel.tsx index c46fcf5..0a0f1be 100644 --- a/src/components/ZapPanel.tsx +++ b/src/components/ZapPanel.tsx @@ -1,24 +1,47 @@ -// 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' +// ZAP panel — fund a position with ONE token: a pool-side token, WETH/USDG, +// or ANY token (custom picker). Shared by POOLS (mint/add) and POSITIONS +// (increase): the parent picks the target, this panel solves the split, +// previews it, and walks the execution sequence. See lib/zap.ts for the math. +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' -import { useAccount } from 'wagmi' -import { useQuery } from '@tanstack/react-query' +import { useAccount, usePublicClient } from 'wagmi' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { formatUnits, parseUnits, type Address } from 'viem' -import { ADDR } from '../config/addresses' +import { ADDR, CHAIN_ID, NATIVE } from '../config/addresses' +import { ENV } from '../config/env' 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 { autoSlippage, retrySlippage, slippagePctToBps, slippageTone, SLIPPAGE_CHOICES, type AutoSlippage } from '../lib/swapGate' +import { SOLVER_AUTO_REFRESHES, solverQuoteAutoRefreshExhausted, solverQuoteCanAutoRefresh } from '../lib/solverRefresh' +import { autostake } from '../lib/autostake' +import { + DEPOSIT_MINS_BPS, + executeZap, + planZap, + stakeableTarget, + zapRouteLabel, + zapStages, + zapVenueFeeBps, + type ZapFailReason, + type ZapPlan, + type ZapTarget, +} from '../lib/zap' import { useBalances } from '../hooks/useBalances' +import { usePools } from '../hooks/usePools' +import { useTokenList } from '../hooks/useTokenList' import type { PoolStat } from '../lib/poolstats' import type { TokenInfo } from '../types' +import { StakeAfterToggle } from './StakeAfterToggle' +import { TokenSelect } from './TokenSelect' import { Btn, NumInput } from './ui' const ETH_GAS_BUFFER = parseUnits('0.001', 18) +const ZAP_PLAN_REFRESH_MS = 30_000 + +const ETH_TOKEN: TokenInfo = { address: NATIVE as Address, symbol: 'ETH', decimals: 18, native: true } +const WETH_TOKEN: TokenInfo = { address: ADDR.WETH, symbol: 'WETH', decimals: 18 } +const USDG_TOKEN: TokenInfo = { address: ADDR.USDG, symbol: 'USDG', decimals: 6 } export function ZapPanel(props: { target: ZapTarget @@ -32,22 +55,45 @@ export function ZapPanel(props: { const { t } = useTranslation() const pool = target.pool const { address: user } = useAccount() + const client = usePublicClient({ chainId: CHAIN_ID }) + const pools = usePools() + const tokenList = useTokenList() + const autoStake = useSyncExternalStore(autostake.subscribe, autostake.get) + const stakeable = stakeableTarget(target) + + // funding candidates: the pair itself, ETH, and the two majors (deduped — + // a WETH/USDG pool doesn't repeat them); anything else via the picker + const candidates = useMemo(() => { + const seen = new Set() + return [t0, t1, ETH_TOKEN, WETH_TOKEN, USDG_TOKEN].filter((tok) => { + const k = tok.address.toLowerCase() + if (seen.has(k)) return false + seen.add(k) + return true + }) + }, [t0, t1]) + const [selTok, setSelTok] = useState(null) + const tIn = selTok ?? t0 + const tokenInAddr: Address = tIn.address + const customSel = !candidates.some((c) => c.address.toLowerCase() === tokenInAddr.toLowerCase()) - 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) + // slippage: null = AUTO (quote-derived); a bps number = a preset chip or the + // typed custom value (customSlip holds the raw text so the input can own focus) + const [manualSlip, setManualSlip] = useState(null) + const [customSlip, setCustomSlip] = useState('') + const [slipOpen, setSlipOpen] = useState(false) + // floor under AUTO after a slippage halt: never re-offer the number that + // just failed. Cleared when the trade context changes or a run succeeds. + const [slipFloor, setSlipFloor] = useState(null) const [running, setRunning] = useState(false) - const [runAt, setRunAt] = useState<{ i: number; failed: boolean } | null>(null) + const [runAt, setRunAt] = useState<{ i: number; failed: boolean; reason?: ZapFailReason; slip?: number } | null>( + null, + ) const [runPlan, setRunPlan] = useState(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 { @@ -59,26 +105,68 @@ export function ZapPanel(props: { return () => clearTimeout(h) }, [amtStr, tIn.decimals]) - const bal = useBalances(user, [t0.address, t1.address, ...(hasWeth ? [NATIVE as Address] : [])]) + // a new pool/token/amount is a new trade — yesterday's failure floor doesn't apply + useEffect(() => { + setSlipFloor(null) + }, [pool.address, tokenInAddr, amount]) + + const balAddrs = useMemo(() => { + const s = new Map() + for (const c of candidates) s.set(c.address.toLowerCase(), c.address) + s.set(tokenInAddr.toLowerCase(), tokenInAddr) + return [...s.values()] + }, [candidates, tokenInAddr]) + const bal = useBalances(user, balAddrs) const balIn = bal.data?.[tokenInAddr.toLowerCase()] - const spendable = balIn === undefined ? undefined : sel === 'eth' ? (balIn > ETH_GAS_BUFFER ? balIn - ETH_GAS_BUFFER : 0n) : balIn + let spendable = balIn + if (balIn !== undefined && tIn.native) spendable = balIn > ETH_GAS_BUFFER ? balIn - ETH_GAS_BUFFER : 0n 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 up33State = pools.error ? 'up33-failed' : pools.data ? 'up33-ready' : 'up33-pending' + const queryClient = useQueryClient() + const zapPlanKey = ['zapPlan', pool.address, lo, hi, tokenInAddr, amount.toString(), up33State] as const const plan = useQuery({ - queryKey: ['zapPlan', pool.address, lo, hi, tokenInAddr, amount.toString()], - enabled: amount > 0n && !running, - refetchInterval: 30_000, + queryKey: zapPlanKey, + // same cap as the swap's solver quote: auto-refresh a few times, then pause + // — execution re-quotes the swap leg fresh regardless, and the manual + // refresh below revives a paused (or failed-out) preview + enabled: (query) => + !!client && !pools.isPending && amount > 0n && !running && solverQuoteCanAutoRefresh(query.state), + refetchInterval: ZAP_PLAN_REFRESH_MS, staleTime: 15_000, - retry: 1, - queryFn: ({ signal }) => planZap({ target, tokenIn: tokenInAddr, amountIn: amount, signal }), + retry: false, + queryFn: () => + planZap({ + client: client!, + pools: pools.error ? null : (pools.data?.pools ?? null), + target, + tokenIn: tokenInAddr, + amountIn: amount, + }), }) 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 + // once the auto-refresh cap is hit the preview stops updating — surface a + // manual refresh. Gate on amount, not on data: four planning FAILURES also + // exhaust the budget, and that is exactly when a retry control is needed. + const planRecord = queryClient.getQueryCache().find({ queryKey: zapPlanKey, exact: true }) + const planPaused = amount > 0n && !running && !!planRecord && solverQuoteAutoRefreshExhausted(planRecord.state) + const autoBase = p?.impactBps == null ? null : autoSlippage(p.impactBps, zapVenueFeeBps(p)) + // post-halt floor wins over the quote-derived number (and stands in for it + // when the impact probe is unavailable — a failed run IS a measurement) + const flooredBps = slipFloor === null ? null : Math.max(autoBase?.bps ?? 0, slipFloor) + const automaticSlippage: AutoSlippage | null = + flooredBps === null ? autoBase : { bps: flooredBps, tone: slippageTone(flooredBps) } + const effectiveSlip = manualSlip ?? automaticSlippage?.bps + const customInvalid = customSlip !== '' && slippagePctToBps(customSlip) === null + const slippageRequired = !!p && p.legs.length > 0 && effectiveSlip === undefined + const stages = useMemo( + () => (p ? zapStages(p, target, tIn, t0, t1, autoStake) : []), + [p, target, tIn, t0, t1, autoStake], + ) // projected APRs on the planned deposit (mint/add previews only — an // increase's projection is the parent card's business) @@ -128,33 +216,28 @@ export function ZapPanel(props: { 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 token = side === 0 ? t0 : t1 + return `${fmtAmount(dust, token.decimals)} ${token.symbol}` } const dusts = [dustNote(0), dustNote(1)].filter(Boolean) const run = async () => { - if (!user || amount === 0n || running) return + const frozen = plan.data + if (!user || !frozen || amount === 0n || running) return + const executionSlippage = frozen.legs.length === 0 ? 0 : effectiveSlip + if (executionSlippage === undefined) return + setRunPlan(frozen) 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, + plan: frozen, target, user, - slipBps: slip, + slipBps: executionSlippage, + stake: autoStake, + tIn, t0, t1, onStage: (i) => setRunAt({ i, failed: false }), @@ -163,9 +246,15 @@ export function ZapPanel(props: { setRunAt(null) setRunPlan(null) setDone(true) + setSlipFloor(null) setAmtStr('') } else { - setRunAt({ i: res.failedAt ?? 0, failed: true }) + const reason = res.reason ?? 'other' + setRunAt({ i: res.failedAt ?? 0, failed: true, reason, slip: executionSlippage }) + // AUTO must not re-offer the tolerance that just failed — only ever raise + if (reason === 'slippage') setSlipFloor((prev) => Math.max(prev ?? 0, retrySlippage(executionSlippage))) + // and the retry must re-plan from a live quote, not the ≤30s cached one + void plan.refetch() } } finally { setRunning(false) @@ -173,48 +262,153 @@ export function ZapPanel(props: { } const failed = runAt?.failed ?? false + let runLabel = t('zap.run') + if (!user) runLabel = t('common.connectWallet') + else if (slippageRequired) runLabel = t('zap.chooseSlippage') + else if (stages.length > 0) runLabel = t('zap.runSteps', { n: stages.length }) + + // slippage: one summary line; the full editor unfolds on demand (or when a + // choice is REQUIRED — no impact probe / invalid custom value) + const slipForced = slippageRequired || customInvalid + const showSlipEditor = slipOpen || slipForced + const slipSummaryTone = customInvalid + ? 'red' + : effectiveSlip !== undefined + ? slippageTone(effectiveSlip) + : 'dim' + const slipSummary = customInvalid + ? t('common.slipInvalid') + : effectiveSlip !== undefined + ? `${manualSlip === null && customSlip === '' ? 'AUTO · ' : ''}${effectiveSlip / 100}%` + : t('zap.chooseSlippage') + + const totalSwapIn = p ? p.legs.reduce((s, l) => s + l.swapIn, 0n) : 0n + const legRows = p + ? p.legs.map((leg, i) => { + const tOut = leg.buyIs0 ? t0 : t1 + return ( +
+ {p.legs.length > 1 ? t('zap.swapN', { n: i + 1 }) : t('zap.swapRow')} + + {fmtAmount(leg.swapIn, tIn.decimals)} {tIn.symbol} → ≈ {fmtAmount(leg.estOut, tOut.decimals)}{' '} + {tOut.symbol} + + + {effectiveSlip === undefined + ? t('zap.chooseSlippage') + : t('zap.swapMin', { + amt: fmtAmount(applySlippage(leg.estOut, effectiveSlip), tOut.decimals), + slip: effectiveSlip / 100, + })} + {leg.impactBps === null ? ( + · {t('zap.impactOff')} + ) : ( + + {' '} + · {t('zap.impact', { pct: (leg.impactBps / 100).toFixed(2) })} + + )} + {/* zap still charges while market swaps are free — keep it bright */} + · {t('zap.terminalFee', { pct: (ENV.zapFeeBps / 100).toFixed(2) })} + {' · '} + {t('zap.via', { route: zapRouteLabel(leg.via) })} + +
+ ) + }) + : null return (
{t('zap.zapIn')} - - - {hasWeth && ( + {candidates.map((c) => ( - )} + ))} + {customSel && } + +
+
{spendable !== undefined && ( <> - - {t('common.bal')} {fmtAmount(spendable, tIn.decimals)} - + + {t('common.bal')} {fmtAmount(spendable, tIn.decimals)} {tIn.symbol} + )} {insufficient && {t('common.exceedsBalance')}}
{t('zap.slip')} - {[50, 100, 300].map((b) => ( - - ))} - {t('zap.slipHint')} + ) : ( + <> + + {SLIPPAGE_CHOICES.map((b) => ( + + ))} + { + setCustomSlip(v) + setManualSlip(slippagePctToBps(v)) + }} + disabled={running} + placeholder={t('common.slipCustom')} + width={96} + invalid={customInvalid} + /> + % + {customInvalid && {t('common.slipInvalid')}} + {effectiveSlip !== undefined && ( + → {effectiveSlip / 100}% + )} + {!slipForced && ( + + )} + + )}
{amount > 0n && plan.isLoading && ( @@ -233,39 +427,37 @@ export function ZapPanel(props: {
{t('zap.split')} - {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, - })} + {p.inIs0 !== null + ? p.legs.length === 0 + ? t('zap.keepAll', { amt: fmtAmount(p.keep, tIn.decimals), sym: tIn.symbol }) + : t('zap.keepSwap', { + keep: fmtAmount(p.keep, tIn.decimals), + swap: fmtAmount(totalSwapIn, tIn.decimals), + sym: tIn.symbol, + }) + : p.legs.length === 2 + ? t('zap.outsideSplit', { + a0: fmtAmount(p.legs.find((l) => l.buyIs0)?.swapIn ?? 0n, tIn.decimals), + a1: fmtAmount(p.legs.find((l) => !l.buyIs0)?.swapIn ?? 0n, tIn.decimals), + sym: tIn.symbol, + sym0: t0.symbol, + sym1: t1.symbol, + }) + : t('zap.outsideOne', { + amt: fmtAmount(totalSwapIn, tIn.decimals), + sym: tIn.symbol, + outSym: p.legs[0]?.buyIs0 ? t0.symbol : t1.symbol, + })} + + + {p.inIs0 !== null + ? p.legs.length === 0 + ? t('zap.splitSdSingle') + : t('zap.splitSd') + : t('zap.outsideSd')} - {p.swapIn === 0n ? t('zap.splitSdSingle') : t('zap.splitSd')}
- {p.swapIn > 0n && tOut && ( -
- {t('zap.swapRow')} - - → ≈ {fmtAmount(p.estOut, tOut.decimals)} {tOut.symbol} - - - {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 - · {t('zap.impactOff')} - ) : ( - 300 ? ' red' : p.impactBps > 150 ? ' amber' : ''}> - {' '} - · {t('zap.impact', { pct: (p.impactBps / 100).toFixed(2) })} - - ))} - {p.routeLabel && <> · {t('zap.via', { route: p.routeLabel })}} - -
- )} + {legRows}
{t('zap.depositRow')} @@ -317,21 +509,29 @@ export function ZapPanel(props: {
)} + {planPaused && ( +
+ +
+ )} + {stages.length > 0 && (
{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' ? '✗' : '·' + let state: 'todo' | 'done' | 'fail' | 'run' = 'todo' + if (runAt && i < runAt.i) state = 'done' + else if (runAt && i === runAt.i && failed) state = 'fail' + else if (runAt && i === runAt.i && running) state = 'run' + let mark = '·' + if (state === 'done') mark = '✓' + else if (state === 'run') mark = '▮' + else if (state === 'fail') mark = '✗' return (
{i + 1} @@ -343,17 +543,30 @@ export function ZapPanel(props: {
)} - {failed &&
{t('zap.halted', { n: (runAt?.i ?? 0) + 1 })}
} + {failed && ( +
+ {runAt?.reason === 'slippage' + ? t('zap.haltedSwap', { + n: (runAt.i ?? 0) + 1, + slip: (runAt.slip ?? 0) / 100, + next: (automaticSlippage?.bps ?? retrySlippage(runAt.slip ?? 0)) / 100, + }) + : runAt?.reason === 'deposit-moved' + ? t('zap.haltedDeposit', { n: (runAt.i ?? 0) + 1, band: DEPOSIT_MINS_BPS / 100 }) + : t('zap.halted', { n: (runAt?.i ?? 0) + 1 })} +
+ )} {done &&
{t('zap.done')}
}
- - {!user - ? t('common.connectWallet') - : stages.length > 0 - ? t('zap.runTx', { n: stages.length }) - : t('zap.run')} + + {runLabel} + {stakeable && } {t('zap.runHint')}
diff --git a/src/components/tabs/BridgeTab.tsx b/src/components/tabs/BridgeTab.tsx new file mode 100644 index 0000000..9c6bba2 --- /dev/null +++ b/src/components/tabs/BridgeTab.tsx @@ -0,0 +1,729 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useAccount } from 'wagmi' +import { formatUnits, parseUnits } from 'viem' +import { CHAIN_ID, NATIVE } from '../../config/addresses' +import { + NATIVE_SENTINEL, + REMOTE_CHAINS, + explorerOf, + remoteById, + resolveIntent, + type BridgeDir, + type BridgeIntent, + type BridgeTokenOption, + type RemoteChain, +} from '../../config/bridge' +import { ENV } from '../../config/env' +import { useBridgeBalance } from '../../hooks/useBridgeBalance' +import { BRIDGE_QUOTE_REFRESH_MS, bestProvider, useBridgeQuotes } from '../../hooks/useBridgeQuotes' +import { useBridgeTokens } from '../../hooks/useBridgeTokens' +import { recheckPending, usePendingBridges } from '../../hooks/usePendingBridges' +import { useTokenUsd } from '../../hooks/useTokenUsd' +import { executeBridge, type BridgeStage } from '../../lib/bridge/exec' +import { fmtEtaShort, nextCheckAt, pendingBridges, type PendingTransfer } from '../../lib/bridge/pending' +import { toTokenOption } from '../../lib/bridge/tokens' +import type { BridgeProviderId } from '../../lib/bridge/types' +import { bpsDiff, fmtAmount, fmtNum, fmtUsd, sanitizeAmountInput } from '../../lib/format' +import { slippageTone } from '../../lib/swapGate' +import { txlog } from '../../lib/txlog' +import type { TokenInfo } from '../../types' +import { Badge, Btn } from '../ui' + +// native MAX keeps gas headroom on the origin chain (L1 needs real room) +const GAS_BUFFER: Record = { 1: parseUnits('0.004', 18) } +const DEFAULT_GAS_BUFFER = parseUnits('0.0015', 18) + +const PROVIDER_LABEL: Record = { + portal: 'PORTAL', + relay: 'RELAY', + across: 'ACROSS', +} + +/** progress strip state: live stage, or 'done' once the deposit is sent + * (fill tracking then lives in the PENDING list, not in the strip) */ +type RunState = { stage: BridgeStage | 'done'; hasApprove: boolean } | null + +export function BridgeTab() { + const { t } = useTranslation() + const { address: user } = useAccount() + const { openConnectModal } = useConnectModal() + + const [dir, setDir] = useState('in') + const [symWanted, setSymWanted] = useState('ETH') + const [remote, setRemote] = useState(REMOTE_CHAINS[0]) + const [amtStr, setAmtStr] = useState('') + const [amount, setAmount] = useState(0n) + const [override, setOverride] = useState(null) + const [feesOpen, setFeesOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [run, setRun] = useState(null) + const [now, setNow] = useState(() => Date.now()) + const runStartRef = useRef(0) + const runEndRef = useRef(0) + + const pending = usePendingBridges() + + // tokens are DISCOVERED per remote from the engines' own support surfaces — + // the dropdown only ever offers same-token routes someone can actually quote + const discovery = useBridgeTokens(remote) + const options: BridgeTokenOption[] = useMemo( + () => (discovery.data ?? []).map((s) => toTokenOption(s, dir)).filter((o) => o.providers.length > 0), + [discovery.data, dir], + ) + // keep the user's pick when it exists here; otherwise fall back (and restore + // automatically when they switch back to a pair that has it) + const token = options.find((o) => o.symbol === symWanted) ?? options[0] ?? null + + const intent: BridgeIntent | null = useMemo( + () => (token ? { dir, token, remote, amount } : null), + [dir, token, remote, amount], + ) + const leg = intent ? resolveIntent(intent) : null + + useEffect(() => { + const handle = setTimeout(() => { + try { + setAmount(parseUnits(amtStr === '' ? '0' : amtStr, token?.decimals ?? 18)) + } catch { + setAmount(0n) + } + }, 350) + return () => clearTimeout(handle) + }, [amtStr, token?.decimals]) + + // switching to a lower-precision token trims typed excess — a 6-decimal + // USDG box must not keep an 18-decimal fraction that silently quotes zero + useEffect(() => { + setAmtStr((s) => (s === '' ? s : sanitizeAmountInput(s, token?.decimals ?? 18) ?? '')) + }, [token?.decimals]) + + useEffect(() => { + setOverride(null) + setRun(null) + }, [dir, token?.symbol, remote.chain.id, amount]) + + // quotes freeze during execution: the run works off a snapshot, and refreshing + // mid-run would burn rate budget and repaint "best" under the user's feet + const quotes = useBridgeQuotes(amount > 0n && !busy ? intent : null, user) + const balance = useBridgeBalance(leg ? user : undefined, leg?.originChainId ?? CHAIN_ID, leg?.inputToken ?? NATIVE_SENTINEL) + const balanceOut = useBridgeBalance(leg ? user : undefined, leg?.destChainId ?? CHAIN_ID, leg?.outputToken ?? NATIVE_SENTINEL) + const usdRef: TokenInfo | null = token + ? token.robinhoodToken.toLowerCase() === NATIVE_SENTINEL.toLowerCase() + ? { address: NATIVE, symbol: 'ETH', decimals: 18, native: true } + : { address: token.robinhoodToken, symbol: token.symbol, decimals: token.decimals } + : null + const usd = useTokenUsd(usdRef) + + const providerIds = token?.providers ?? [] + const automatic = bestProvider(quotes, providerIds) + const selectedProvider = override ?? automatic + const selected = selectedProvider ? quotes[selectedProvider].data ?? null : null + const anyFetching = providerIds.some((id) => quotes[id].isFetching) + + const hasLivePending = pending.some((p) => p.status === 'pending') + const ticking = amount > 0n || run !== null || hasLivePending + useEffect(() => { + if (!ticking) return + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, [ticking]) + + const originName = dir === 'in' ? remote.label : 'ROBINHOOD' + const destName = dir === 'in' ? 'ROBINHOOD' : remote.label + const balIn = balance.data + const insufficient = balIn !== undefined && amount > balIn + const isNativeIn = !!leg && leg.inputToken.toLowerCase() === NATIVE_SENTINEL.toLowerCase() + const guard = (fn: () => void) => () => { + if (!busy) fn() + } + + const gasBuffer = leg ? GAS_BUFFER[leg.originChainId] ?? DEFAULT_GAS_BUFFER : DEFAULT_GAS_BUFFER + const setPct = (pct: bigint) => { + if (balIn === undefined || !leg) return + // gas headroom only gates MAX — a partial percentage already leaves the + // rest for gas, and reserving it there zeroed out small balances entirely + const buffer = pct === 100n && isNativeIn ? gasBuffer : 0n + const spendable = balIn > buffer ? balIn - buffer : 0n + setAmtStr(formatUnits((spendable * pct) / 100n, leg.inputDecimals)) + } + + const fmtEtaDisplay = (sec: number): string => + sec >= 90 ? t('bridge.etaMin', { m: Math.max(1, Math.round(sec / 60)) }) : t('bridge.etaS', { s: Math.max(1, sec) }) + + // token-ratio economics (same-token legs: what arrives vs what was sent) + const inNum = amount > 0n && leg ? Number(formatUnits(amount, leg.inputDecimals)) : 0 + const outNum = selected && leg ? Number(formatUnits(selected.outputAmount, leg.outputDecimals)) : 0 + const rate = selected && inNum > 0 ? outNum / inNum : undefined + const impactPct = rate !== undefined ? (rate - 1) * 100 : undefined + const costBps = impactPct !== undefined ? Math.max(0, Math.round(-impactPct * 100)) : null + const inUsd = usd.data !== undefined && inNum > 0 ? inNum * usd.data : undefined + const outUsd = usd.data !== undefined && selected ? outNum * usd.data : undefined + + let outputDisplay = '0.0' + if (selected && leg) outputDisplay = fmtAmount(selected.outputAmount, leg.outputDecimals) + else if (anyFetching && amount > 0n) outputDisplay = '…' + + // refresh countdown tracks the NETWORK quotes — the portal quote is a local + // 1:1 construction and never refreshes + const netIds = providerIds.filter((id) => id !== 'portal') + const lastNet = Math.max(0, ...netIds.map((id) => quotes[id].dataUpdatedAt || 0)) + const nextIn = lastNet > 0 ? Math.max(0, Math.ceil((lastNet + BRIDGE_QUOTE_REFRESH_MS - now) / 1000)) : null + + const doBridge = async () => { + if (!user || !selected || !leg || amount === 0n) return + setBusy(true) + runStartRef.current = Date.now() + const hasApprove = selected.steps.some((s) => s.kind === 'approve') + try { + const outcome = await executeBridge( + selected, + user, + { + leg, + amountInStr: amtStr, + depositLabel: t('bridge.stDeposit', { amt: amtStr, sym: leg.inputSymbol, from: originName, to: destName }), + }, + (stage) => setRun({ stage, hasApprove }), + ) + runEndRef.current = Date.now() + // sent = deposit confirmed, transfer now tracked in PENDING; null = the + // log line already told the rejection/revert story + setRun(outcome === 'sent' ? { stage: 'done', hasApprove } : null) + } catch (error) { + txlog.push('err', (error as Error).message) + setRun(null) + } finally { + setBusy(false) + } + } + + // ---- CTA state machine (mirrors the swap tab) ---- + let cta = t('bridge.noRoute') + let ctaDisabled = true + if (!user) { + cta = t('common.connectWallet') + ctaDisabled = false + } else if (!token) cta = discovery.isError ? t('bridge.discoverFailed') : t('bridge.discovering') + else if (amount === 0n) cta = t('swap.ctaEnterAmount') + else if (insufficient) cta = t('common.insufficientBalance') + else if (!selected && anyFetching) cta = t('swap.ctaQuoting') + else if (selected && selectedProvider) { + cta = t('bridge.execVia', { provider: PROVIDER_LABEL[selectedProvider] }) + ctaDisabled = false + } + + // canonical bridge model: direction is WHERE the chains sit, not a mode. Each + // card carries its own chain selector; Robinhood always occupies one side, so + // placing a chain on this card snaps the opposite card to the other end. + const chainTip = t('bridge.chainTip', { id: CHAIN_ID }) + const pickChain = (side: 'from' | 'to') => (id: number) => { + if (busy) return + if (id === CHAIN_ID) { + setDir(side === 'from' ? 'out' : 'in') + } else { + const r = remoteById(id) + if (!r) return + setRemote(r) + setDir(side === 'from' ? 'in' : 'out') + } + } + + const pendingSection = pending.length > 0 && ( + <> +
{t('bridge.pendingTitle')}
+
+ {pending.map((p) => ( + + ))} +
+ + ) + + const sendCard = ( +
+
+ + {t('bridge.from')} + + + {balIn !== undefined && leg ? ( + + ) : balance.isError ? ( + // a failed read must say so — an invisible balance reads as "you have none" + + ) : null} +
+
+ + { + const value = sanitizeAmountInput(event.target.value, token?.decimals ?? 18) + if (value !== null) setAmtStr(value) + }} + /> +
+
+ + {balIn !== undefined && + balIn > 0n && + [25n, 50n, 75n, 100n].map((pct) => ( + + ))} + + {inUsd !== undefined && <>≈ {fmtUsd(inUsd)}} +
+ {discovery.isError && options.length === 0 && ( +
+ {t('bridge.discoverFailed')} + +
+ )} +
+ ) + + const receiveCard = ( +
+
+ + {t('bridge.to')} + + + {balanceOut.data !== undefined && leg && ( + + {t('common.bal')} {fmtAmount(balanceOut.data, leg.outputDecimals)} {leg.outputSymbol} + + )} +
+
+ {token?.symbol ?? '—'} + {outputDisplay} +
+
+ + + {outUsd !== undefined && <>≈ {fmtUsd(outUsd)}} + {impactPct !== undefined && ( + + {' '} + ({impactPct > 0 ? '+' : ''} + {impactPct.toFixed(2)}%) + + )} + +
+
+ ) + + // provider rows sorted by destination output — price decides the order, so + // the lossless canonical route leads whenever it quotes (even at ~10 min) + const sortedIds = useMemo(() => { + const score = (id: BridgeProviderId) => quotes[id].data?.outputAmount ?? null + return [...providerIds].sort((a, b) => { + const qa = score(a) + const qb = score(b) + if (qa !== null && qb !== null) return qb > qa ? 1 : qb < qa ? -1 : 0 + if (qa !== null) return -1 + if (qb !== null) return 1 + return (quotes[a].isFetching ? 0 : 1) - (quotes[b].isFetching ? 0 : 1) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [providerIds.join(), quotes.relay.data, quotes.across.data, quotes.portal.data, quotes.relay.isFetching, quotes.across.isFetching, quotes.portal.isFetching]) + + const bestQuote = automatic ? quotes[automatic].data ?? null : null + const runOver = run?.stage === 'done' + const elapsed = run + ? Math.max(0, Math.floor(((runOver ? runEndRef.current : now) - runStartRef.current) / 1000)) + : 0 + const runStages: (BridgeStage | 'done')[] = run?.hasApprove ? ['approve', 'deposit', 'done'] : ['deposit', 'done'] + const stageLabel: Record = { + approve: t('bridge.stgApprove'), + deposit: t('bridge.stgDeposit'), + done: t('bridge.stgSent'), + } + + const progressStrip = run && ( +
+ {runStages.map((s, i) => { + const cur = runStages.indexOf(run.stage) + const isDone = run.stage === 'done' || i < cur + const isCurrent = i === cur && run.stage !== 'done' + const cls = isDone ? 'done' : isCurrent ? 'on' : '' + return ( + + {i > 0 && } + + {isDone && '✓'} + {isCurrent && } {stageLabel[s]} + + + ) + })} + {runOver && {t('bridge.sentNote')}} + {elapsed}s +
+ ) + + return ( +
+
+ {pendingSection} + {sendCard} +
+ +
+ {receiveCard} + + {amount > 0n && token && leg && ( + <> +
+ {t('bridge.route')} + +
+ {sortedIds.map((id) => { + const query = quotes[id] + const quote = query.data ?? null + const isSel = selectedProvider === id + const behindBest = + quote && bestQuote && automatic !== id ? bpsDiff(quote.outputAmount, bestQuote.outputAmount) / 100 : null + const qImpact = + quote && inNum > 0 + ? (Number(formatUnits(quote.outputAmount, leg.outputDecimals)) / inNum - 1) * 100 + : null + return ( +
setOverride(id))}> +
+ + {isSel ? '◉' : '○'} {PROVIDER_LABEL[id]} + + {id === 'portal' && ( + + {t('bridge.canonical')} + + )} + {query.isFetching && !quote && } + {query.isError && !quote && ( + + {((query.error as Error).message ?? '').slice(0, 70) || t('bridge.quoteFailed')} + + )} + {quote && ( + <> + + {fmtAmount(quote.outputAmount, leg.outputDecimals)} {leg.outputSymbol} + + {automatic === id ? ( + {t('swap.best')} + ) : ( + behindBest !== null && + Math.abs(behindBest) >= 0.005 && ( + + {behindBest > 0 ? '+' : ''} + {behindBest.toFixed(2)}% + + ) + )} + + )} +
+ {quote && ( +
+ + {fmtEtaDisplay(quote.etaSec)} + {qImpact !== null && ( + + {' '} + · {t('swap.quoteImpact', { pct: qImpact.toFixed(2) })} + + )} + + + {t('bridge.minShort', { + amt: fmtAmount(quote.minOutput, leg.outputDecimals), + sym: leg.outputSymbol, + })} + +
+ )} +
+ ) + })} + + {impactPct !== undefined && impactPct <= -1 && ( +
{t('bridge.impactWarn', { pct: impactPct.toFixed(2) })}
+ )} + + {selected && ( + <> +
{t('swap.details')}
+
+ {rate !== undefined && ( +
+ {t('swap.kRate')} + + + {t('swap.rateV', { a: leg.inputSymbol, n: fmtNum(rate), b: leg.outputSymbol })} + +
+ )} +
+ {t('swap.kMinReceived')} + + + {fmtAmount(selected.minOutput, leg.outputDecimals)} {leg.outputSymbol} + +
+
+ {t('bridge.kEta')} + + {fmtEtaDisplay(selected.etaSec)} +
+ {/* fee breakdown only exists with a terminal fee — at 0 the + provider-cost row would just repeat IMPACT */} +
0 ? 'kv click' : 'kv'} + onClick={ENV.bridgeFeeBps > 0 ? () => setFeesOpen(!feesOpen) : undefined} + title={t('bridge.impactTip')} + > + {t('swap.kImpact')} + + + {impactPct === undefined ? '—' : `${impactPct.toFixed(2)}%`} + {ENV.bridgeFeeBps > 0 && {feesOpen ? '▴' : '▾'}} + +
+ {ENV.bridgeFeeBps > 0 && feesOpen && ( + <> +
+ {t('bridge.kProviderCost')} + + + {impactPct === undefined + ? '—' + : `${(impactPct + ENV.bridgeFeeBps / 100).toFixed(2)}%`} + +
+
+ {t('swap.kTerminalFee')} + + {(ENV.bridgeFeeBps / 100).toFixed(2)}% +
+ + )} +
+ + )} + + )} + + {progressStrip} + +
+ openConnectModal?.() : doBridge}> + {cta} + +
+ +
+
+ ) +} + +function chainName(id: number): string { + return id === CHAIN_ID ? 'ROBINHOOD' : remoteById(id)?.label ?? `#${id}` +} + +/** one persisted transfer: countdown while inside the ETA, conservative + * verification after it, terminal states with the fill link */ +function PendingRow({ p, now }: { p: PendingTransfer; now: number }) { + const { t } = useTranslation() + const etaLeftSec = Math.ceil((p.createdAt + p.etaSec * 1000 - now) / 1000) + const canRecheck = p.status === 'stale' || (p.status === 'pending' && etaLeftSec <= 0) + return ( +
+ + {p.status === 'pending' && } + {p.status === 'filled' && '✓'} + {(p.status === 'refunded' || p.status === 'failed' || p.status === 'stale') && '⚠'} + + + {p.amountIn} {p.symbol} + + + {chainName(p.originChainId)}→{chainName(p.destChainId)} · {PROVIDER_LABEL[p.provider]} + + + ↗ + + + {p.status === 'pending' && + (etaLeftSec > 0 + ? t('bridge.etaLeft', { eta: fmtEtaShort(etaLeftSec) }) + : `${t('bridge.verifying')} ${Math.max(0, Math.ceil((nextCheckAt(p) - now) / 1000))}s`)} + {p.status === 'filled' && + (p.fillTxHash ? ( + + {t('bridge.pFilled')} ↗ + + ) : ( + t('bridge.pFilled') + ))} + {p.status === 'refunded' && t('bridge.pRefunded')} + {p.status === 'failed' && t('bridge.pFailed')} + {p.status === 'stale' && t('bridge.pStale')} + + {canRecheck && ( + + )} + +
+ ) +} + +/** token dropdown fed by live route discovery (tsel popover chrome) */ +function TokenSel(props: { + options: BridgeTokenOption[] + value: BridgeTokenOption | null + busy: boolean + loading: boolean + onPick: (symbol: string) => void +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ {props.options.map((o) => ( +
{ + props.onPick(o.symbol) + setOpen(false) + }} + > + + {props.value?.symbol === o.symbol ? '◉' : '○'} {o.symbol} + + + {o.providers.length === 1 ? t('bridge.nRoute1') : t('bridge.nRoutes', { n: o.providers.length })} + +
+ ))} + {props.options.length === 0 && ( +
{props.loading ? t('bridge.discovering') : t('bridge.noTokens')}
+ )} +
+ + )} +
+ ) +} + +/** per-card chain endpoint selector (tsel popover chrome, five fixed entries) */ +function ChainSel(props: { current: number; busy: boolean; tip: string; onPick: (id: number) => void }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + return ( + e.key === 'Escape' && setOpen(false)}> + + {open && ( + <> +
setOpen(false)} /> +
+ {[CHAIN_ID, ...REMOTE_CHAINS.map((r) => r.chain.id)].map((id) => ( + + ))} +
+ + )} + + ) +} diff --git a/src/components/tabs/LabTab.tsx b/src/components/tabs/LabTab.tsx index 60de968..2113478 100644 --- a/src/components/tabs/LabTab.tsx +++ b/src/components/tabs/LabTab.tsx @@ -7,7 +7,8 @@ import { ProtoBadge } from '../ProtoBadge' import { RangeBar } from '../RangeBar' import { Badge, Btn } from '../ui' import { AddCl, AddV2 } from './PoolsTab' -import { ClCard, IncreasePanel, V2Card } from './PositionsTab' +import { clPosMetrics } from '../../lib/posmetrics' +import { ClCard, ClPoolGroup, IncreasePanel, V2Card } from './PositionsTab' const LAB_POOL: ClPool = { kind: 'cl', @@ -230,6 +231,47 @@ export function LabTab() { ) })()} +
POOL GROUP — same-pool aggregation (2+ positions collapse under one header)
+ {(() => { + const gaugePool: ClPool = { + ...LAB_POOL, + gauge: '0x00000000000000000000000000000000000000aa', + rewardRate: 5_000_000_000_000_000n, + } + const stat = { vol24hUsd: 155_000, liqUsd: 184_000, source: 'dexscreener' as const } + const labUsd = { upUsd: 0.0688, wethUsd: 1928 } + const staked1: 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 staked2: 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), + } + return ( + + clPosMetrics({ pos: p, amount0: p.amount0, amount1: p.amount1, tick: p.pool.tick, dec0: 18, dec1: 18, stat, ...labUsd }) + } + data={LAB_DATA} + xtokens={{}} + user={LAB_USER} + liveOf={() => undefined} + statOf={() => stat} + upUsd={labUsd.upUsd} + wethUsd={labUsd.wethUsd} + /> + ) + })()} +
INCREASE PANEL LAB (synthetic positions)
{(() => { const inRange = labPos(100000, 104000, 500_000_000_000_000_000_000n) diff --git a/src/components/tabs/PoolsTab.tsx b/src/components/tabs/PoolsTab.tsx index e0399b5..cb8ddfd 100644 --- a/src/components/tabs/PoolsTab.tsx +++ b/src/components/tabs/PoolsTab.tsx @@ -2,7 +2,7 @@ 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 { formatUnits, parseUnits, type TransactionReceipt } from 'viem' import { clPmAbi, uniV2RouterAbi, uniV3PmAbi, v2RouterAbi } from '../../abi' import { ADDR, CHAIN_ID, EXPLORER, UNI, WEEK } from '../../config/addresses' import { wagmiConfig } from '../../config/wagmi' @@ -29,19 +29,26 @@ import { stakedShareOf, type AddSim, } from '../../lib/apr' -import { fmtAmount, fmtCompactAmount, fmtNum, fmtUsd, nowSec } from '../../lib/format' -import { deadline, ensureAllowance, fetchSqrtPriceX96, step } from '../../lib/tx' +import { fmtAmount, fmtCompact, fmtCompactAmount, fmtNum, fmtUsd, nowSec } from '../../lib/format' +import { poolStatWithFallback } from '../../lib/poolStatFallback' +import { deadline, ensureAllowance, fetchSqrtPriceX96, receivedOf, step } from '../../lib/tx' +import { autostake } from '../../lib/autostake' +import { poolStakeable } from '../../lib/zap' +import { stakeClNft, stakeV2Lp, mintedTokenId } from '../../lib/stake' import { useBalances } from '../../hooks/useBalances' import { usePositions } from '../../hooks/usePositions' -import { usePoolStats } from '../../hooks/usePoolStats' +import { useDsFallbackStats, 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 { DexScreenerLink } from '../DexScreenerLink' import { Flash } from '../Flash' +import { PairAddrs } from '../PairAddrs' import { ProtoBadge } from '../ProtoBadge' import { RangeBar } from '../RangeBar' +import { StakeAfterToggle } from '../StakeAfterToggle' import { ZapPanel } from '../ZapPanel' import { AmountRow, Btn, NumInput } from '../ui' @@ -50,6 +57,10 @@ const SLIP_BPS = 100 type SortKey = 'vol' | 'fees24' | 'tvl' | 'feeApr' | 'rewards' | null type ProtoFilter = 'all' | 'up33' | 'univ3' | 'univ2' +// Top-level tabs unmount; retain only the browsing choices for this session. +let rememberedSort: SortKey = 'vol' +let rememberedProto: ProtoFilter = 'all' + export function PoolsTab() { const { t } = useTranslation() const pools = usePools() @@ -59,13 +70,24 @@ export function PoolsTab() { const positions = usePositions(user) const [q, setQ] = useState('') // one input: filters up33 locally + queries the indexer const [open, setOpen] = useState(null) - const [sort, setSort] = useState('tvl') // browse default: biggest pools first + const [sort, setSortState] = useState(rememberedSort) const [onlyMine, setOnlyMine] = useState(false) - const [proto, setProto] = useState('all') + const [proto, setProtoState] = useState(rememberedProto) 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 + // APR explainer popover, anchored under the ⓘ that opened it (fixed: the + // sticky thead is its own stacking context, so the pop must live outside) + const [aprInfo, setAprInfo] = useState<{ top: number; right: number } | null>(null) const uni = useUniPools(uniQuery, hideDust ? 1_000 : 0, proto === 'univ2' || proto === 'univ3' ? proto : undefined) const filterRef = useRef(null) + const setSort = (next: SortKey) => { + rememberedSort = next + setSortState(next) + } + const setProto = (next: ProtoFilter) => { + rememberedProto = next + setProtoState(next) + } // typing filters the local list instantly; the catalog query follows 350ms behind useEffect(() => { @@ -94,6 +116,40 @@ export function PoolsTab() { return s }, [positions.data]) + // stats resolve in three layers: up33 primary (dexscreener CL + official v2 + // subgraph) → uniswap indexer (chain TVL + geckoterminal volume) → dexscreener + // backstop for rows still missing a number (geckoterminal only tracks its + // listed subset — measured 53 of the top-200 TVL rows). Field-level merge so + // the indexer's chain-derived TVL keeps priority over dexscreener's estimate. + const dsFallbackAddrs = useMemo(() => { + if (!stats.data) return [] // wait for the primary pass — else the first render batches the whole catalog + const byPool = stats.data.byPool + const uniStats = uni.data?.stats + const out: string[] = [] + for (const p of [...(pools.data?.pools ?? []), ...(uni.data?.pools ?? [])]) { + const a = p.address.toLowerCase() + const s = byPool[a] ?? uniStats?.[a] + if (!s || s.vol24hUsd == null || s.liqUsd == null) out.push(a) + if (out.length >= 90) break // 3 batched calls — rows past the cap keep their "—" + } + return out + }, [pools.data, uni.data, stats.data]) + const dsFb = useDsFallbackStats(dsFallbackAddrs) + const mergedStats = useMemo(() => { + const byPool = stats.data?.byPool + const uniStats = uni.data?.stats + const out: Record = {} + for (const p of [...(pools.data?.pools ?? []), ...(uni.data?.pools ?? [])]) { + const a = p.address.toLowerCase() + const base = byPool?.[a] ?? uniStats?.[a] + const fb = dsFb.data?.[a] + const s = poolStatWithFallback(base, fb) + if (s) out[a] = s + } + return out + }, [pools.data, uni.data, stats.data, dsFb.data]) + const statOf = (p: Pool) => mergedStats[p.address.toLowerCase()] + if (pools.isLoading) return (
@@ -108,9 +164,6 @@ export function PoolsTab() { 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 @@ -134,14 +187,37 @@ export function PoolsTab() { } const totalWeight = data.protocol.totalWeight - const th = (key: Exclude, label: string) => ( + const th = (key: Exclude, label: string, extra = '', sub?: string, info = false) => ( setSort(sort === key ? null : key)} title={t('pools.sortTip')} > {label} {sort === key ? ' ▼' : ''} + {/* the space is load-bearing: JSX eats the newline, and without a break + opportunity this inline-block cannot wrap — on a 320px phone it hung + 4px past the table and put the sideways scrollbar back */} + {info && ' '} + {info && ( + + )} + {/* mobile stacks a second metric into this column; name it in the header */} + {sub && {sub}} ) @@ -218,13 +294,20 @@ export function PoolsTab() { {t('pools.thPair')} - {t('pools.thPrice')} - {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'))} - + {/* mobile keeps two stacked columns: TVL/VOL and FEE APR/REWARDS. + PRICE/RESERVES is the widest cell in the table and the first to + go: below a laptop it was pushing TVL and VOL off the right + edge, and its content ("24.9M CASHCAT + 12.4 WETH") has no + natural width bound. */} + {t('pools.thPrice')} + {th('tvl', t('pools.thTvl'), '', t('pools.thVol'))} + {th('vol', t('pools.thVol'), 'hide-m')} + {th('fees24', t('pools.thFees'), 'hide-m')} + {th('feeApr', t('pools.thFeeApr'), '', t('pools.thRewards'), true)} + {th('rewards', t('pools.thRewards'), 'hide-m')} + {/* the row itself is the toggle everywhere, so this column is pure + width below a laptop — same trade the phone layout already made */} + @@ -246,9 +329,14 @@ export function PoolsTab() {
-
- , ]} /> -
+ {aprInfo && ( + <> +
setAprInfo(null)} /> +
+ , ]} /> +
+ + )}
) } @@ -281,18 +369,35 @@ function PoolRow(props: { return ( <> - - -
- {props.mine && ( - - ● - - )} - - {t0.symbol}/{t1.symbol} - - {p.protocol !== 'up33' && } + { + // the row is the toggle, but the controls inside it own their own + // clicks: ADD LP already calls onToggle (bubbling would fire it a + // second time and snap the panel shut), and ↗ goes to the explorer. + // closest() covers anything interactive added here later. + if ((e.target as HTMLElement).closest('button, a')) return + // a click that ends a drag-select is the user copying a number, + // not asking to expand + if (window.getSelection()?.toString()) return + props.onToggle() + }} + > + +
+ + {/* every protocol gets its mark, UP33 included — it was the unmarked + default back when this table was UP33-only, but in a three-protocol + list "no badge" is not an identity. POSITIONS already badges all three. */} + +
@@ -314,23 +419,17 @@ function PoolRow(props: { > {feePct.toFixed(feePct < 0.1 ? 3 : 2)}% - {p.protocol === 'up33' && ( + {/* a killed gauge still matters (staking there earns nothing) — the + healthy/no-gauge states were the noise and are gone */} + {p.protocol === 'up33' && p.gauge && !p.gaugeAlive && ( <> {' · '} - {p.gauge ? ( - p.gaugeAlive ? ( - {t('pools.gauge')} - ) : ( - {t('pools.killed')} - ) - ) : ( - {t('pools.noGauge')} - )} + {t('pools.killed')} )}
- + {p.kind === 'v2' ? ( <> {fmtCompactAmount(p.reserve0, t0.decimals)} {t0.symbol} + {fmtCompactAmount(p.reserve1, t1.decimals)} {t1.symbol} @@ -340,18 +439,42 @@ function PoolRow(props: { )} - {stat?.liqUsd != null && stat.liqUsd > 0 ? fmtUsd(stat.liqUsd) : } + + {stat?.liqUsd != null && stat.liqUsd > 0 ? ( + <> + {fmtUsd(stat.liqUsd)} + {/* phone columns: $2.8M, not $2,844,349 */} + ${fmtCompact(stat.liqUsd)} + + ) : ( + + )} + + {/* phone: VOL 24H stacks under TVL */} + + {stat?.vol24hUsd != null ? `$${fmtCompact(stat.vol24hUsd)}` : '—'} + - - {stat?.vol24hUsd != null ? fmtUsd(stat.vol24hUsd) : } + + + {stat?.vol24hUsd != null ? fmtUsd(stat.vol24hUsd) : } + - + {fees24 != null ? {fmtUsd(fees24)} : } {feeApr != null ? fmtApr(feeApr) : } + {/* phone: reward APR stacks under fee APR */} + + {p.protocol === 'up33' && emitApr != null ? ( + {fmtApr(emitApr)} + ) : ( + '—' + )} + - + {p.protocol === 'up33' ? ( <> {emitApr != null ? {fmtApr(emitApr)} : } @@ -367,7 +490,8 @@ function PoolRow(props: { )} - + {/* phone and tablet: the row itself is the toggle, the button column just steals width */} + {props.open ? t('common.close') : t('pools.addLp')} {' '} @@ -444,7 +568,7 @@ export function AddV2({ pool, data, stat, upUsd }: { pool: V2Pool; data: PoolsDa const { address: user } = useAccount() const t0 = tokenOf(data, pool.token0) const t1 = tokenOf(data, pool.token1) - const [fund, setFund] = useState<'pair' | 'zap'>('pair') + const [fund, setFund] = useState<'pair' | 'zap'>('zap') const [a0, setA0] = useState('') const [a1, setA1] = useState('') const [busy, setBusy] = useState(false) @@ -487,6 +611,7 @@ export function AddV2({ pool, data, stat, upUsd }: { pool: V2Pool; data: PoolsDa const add = async () => { if (!user) return + const stakeAfter = autostake.get() // captured at click, like the zap flow setBusy(true) try { const amt0 = safeParse(a0, t0.decimals) @@ -494,11 +619,12 @@ export function AddV2({ pool, data, stat, upUsd }: { pool: V2Pool; data: PoolsDa 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 + let rcpt: TransactionReceipt | null 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}` }), () => + rcpt = await step(t('add.stepAddV2', { pair: `${t0.symbol}/${t1.symbol}` }), () => writeContract(wagmiConfig, { abi: uniV2RouterAbi, address: UNI.V2_ROUTER, @@ -516,34 +642,40 @@ export function AddV2({ pool, data, stat, upUsd }: { pool: V2Pool; data: PoolsDa 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, { + } else { + const quote = await readContract(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(), - ], + functionName: 'quoteAddLiquidity', + args: [pool.token0, pool.token1, pool.stable, ADDR.V2_FACTORY, amt0, amt1], chainId: CHAIN_ID, - }), - ) + }) + rcpt = 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, + }), + ) + } + // up33 v2 with a live gauge + pref on → stake the freshly minted LP + // (uniswap v2 has no gauge, so poolStakeable gates it out cleanly) + if (rcpt && stakeAfter && poolStakeable(pool) && pool.gauge) { + const lp = receivedOf(rcpt, pool.address, user) + if (lp > 0n) await stakeV2Lp(pool.address, pool.gauge, lp, user, `${t0.symbol}/${t1.symbol}`) + } } finally { setBusy(false) } @@ -577,6 +709,7 @@ export function AddV2({ pool, data, stat, upUsd }: { pool: V2Pool; data: PoolsDa {t('add.addLiquidity')} + {poolStakeable(pool) && } {t('add.v2Hint', { slip: SLIP_BPS / 100 })} · {uni2 ? t('add.v2HintUni') : t('add.v2HintUp33')} @@ -594,13 +727,6 @@ export function FundSwitch(props: { fund: 'pair' | 'zap'; onFund: (f: 'pair' | ' return (
{t('add.fund')} - +
) } @@ -658,8 +791,9 @@ export function AddCl({ const { address: user } = useAccount() const t0 = tokenOf(data, pool.token0) const t1 = tokenOf(data, pool.token1) - const [fund, setFund] = useState<'pair' | 'zap'>('pair') + const [fund, setFund] = useState<'pair' | 'zap'>('zap') const [mode, setMode] = useState('p10') + const [advOpen, setAdvOpen] = useState(false) const [pctStr, setPctStr] = useState('10') const [priceLo, setPriceLo] = useState('') const [priceHi, setPriceHi] = useState('') @@ -720,6 +854,8 @@ export function AddCl({ setMode('price') } + const advActive = mode === 'pct' || mode === 'above' || mode === 'below' || mode === 'price' || mode === 'ticks' + const below = ticks ? pool.tick < ticks.lower : false const above = ticks ? pool.tick >= ticks.upper : false @@ -775,6 +911,7 @@ export function AddCl({ const mint = async () => { if (!user || !ticks) return + const stakeAfter = autostake.get() // captured at click, like the zap flow setBusy(true) try { const amt0 = safeParse(a0, t0.decimals) @@ -802,7 +939,7 @@ export function AddCl({ recipient: user, deadline: deadline(), } - await step( + const rcpt = await step( t('add.stepMint', { kind: pool.protocol === 'univ3' ? 'v3' : 'CL', pair: `${t0.symbol}/${t1.symbol}`, @@ -826,6 +963,12 @@ export function AddCl({ chainId: CHAIN_ID, }), ) + // continue into staking when the pool is stakeable and the pref is on — + // the mint receipt carries the new tokenId, so it's exact, never a guess + if (rcpt && stakeAfter && poolStakeable(pool) && pool.gauge) { + const tokenId = mintedTokenId(rcpt, npm, user) + if (tokenId !== null) await stakeClNft(pool.gauge, npm, tokenId) + } } finally { setBusy(false) } @@ -843,7 +986,17 @@ export function AddCl({ + {/* one-sided / custom-% / price / tick modes live behind MORE — five + extra controls the common preset flow never needs to look at */} +
+ {(advOpen || advActive) && (
+ )} {ticks && ( {t('add.mint')} + {poolStakeable(pool) && } {pool.protocol === 'univ3' ? t('add.mintHintUni') : t('add.mintHintUp33')} diff --git a/src/components/tabs/PositionsTab.tsx b/src/components/tabs/PositionsTab.tsx index ed36b49..e179a1d 100644 --- a/src/components/tabs/PositionsTab.tsx +++ b/src/components/tabs/PositionsTab.tsx @@ -1,9 +1,9 @@ -import { useMemo, useState } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { useAccount } from 'wagmi' import { readContract, writeContract } from 'wagmi/actions' import { parseUnits } from 'viem' -import { clGaugeAbi, clPmAbi, v2GaugeAbi, v2PoolAbi, v2RouterAbi } from '../../abi' +import { clGaugeAbi, clPmAbi, uniV2RouterAbi, v2GaugeAbi, v2PoolAbi, v2RouterAbi } from '../../abi' import { ADDR, CHAIN_ID, EXPLORER, UNI } from '../../config/addresses' import { wagmiConfig } from '../../config/wagmi' import { @@ -18,9 +18,10 @@ import { import { fmtApr } from '../../lib/apr' import { fmtAmount, fmtNum, fmtUsd, shortAddr } from '../../lib/format' import { limitFillFrac, limitSideFor, limitTagOf, untagLimit } from '../../lib/limit' -import { clPosMetrics, v2PosMetrics, type Earning } from '../../lib/posmetrics' +import { compareClPositionDisplay, clPosMetrics, v2PosMetrics, type ClMetrics, type Earning } from '../../lib/posmetrics' import type { PoolStat } from '../../lib/poolstats' import { deadline, ensureAllowance, fetchSqrtPriceX96, offerSwapClaimedUp, step } from '../../lib/tx' +import { stakeClNft, stakeV2Lp } from '../../lib/stake' import { txlog } from '../../lib/txlog' import { tokenOf, usePools } from '../../hooks/usePools' import { useBalances } from '../../hooks/useBalances' @@ -31,7 +32,9 @@ import { useUpPrice } from '../../hooks/useUpPrice' import { useLiveSlot0, type LiveSlot0 } from '../../hooks/useLiveSlot0' import type { Address } from 'viem' import type { ClPosition, Pool, PoolsData, TokenInfo, V2Position } from '../../types' +import { DexScreenerLink } from '../DexScreenerLink' import { Flash } from '../Flash' +import { PairAddrs } from '../PairAddrs' import { ProtoBadge } from '../ProtoBadge' import { RangeBar } from '../RangeBar' import { ZapPanel } from '../ZapPanel' @@ -49,10 +52,14 @@ export function PositionsTab() { const upPrice = useUpPrice() const [claimBusy, setClaimBusy] = useState(false) - // indexer stats for the specific uniswap pools the user is LPing + // indexer stats for the specific uniswap pools the user is LPing (v3 + v2) const uniAddrs = useMemo( - () => - [...new Set((positions.data?.cl ?? []).filter((p) => p.pool.protocol === 'univ3').map((p) => p.pool.address))], + () => [ + ...new Set([ + ...(positions.data?.cl ?? []).filter((p) => p.pool.protocol === 'univ3').map((p) => p.pool.address), + ...(positions.data?.v2 ?? []).filter((p) => p.pool.protocol === 'univ2').map((p) => p.pool.address), + ]), + ], [positions.data], ) const uniStats = useUniPoolStats(uniAddrs) @@ -69,9 +76,9 @@ export function PositionsTab() { const live = useLiveSlot0(orderPools) const liveOf = (p: ClPosition): LiveSlot0 | undefined => live.data?.[p.pool.address.toLowerCase()] const statOf = (pool: Pool): PoolStat | undefined => - pool.protocol === 'univ3' - ? uniStats.data?.[pool.address.toLowerCase()] - : stats.data?.byPool[pool.address.toLowerCase()] + pool.protocol === 'up33' + ? stats.data?.byPool[pool.address.toLowerCase()] + : uniStats.data?.[pool.address.toLowerCase()] const claimables = useMemo(() => { const cl = positions.data?.cl.filter((p) => p.staked && p.earned > 0n && p.pool.gauge) ?? [] @@ -102,7 +109,10 @@ export function PositionsTab() { // portfolio roll-up: value / uncollected fees / UP accrual rate. Uses the // 15s positions snapshot (cards refine with the fast feed where they have it) const tokAll: Record = { ...pools.data.tokens, ...data.tokens } - const clVal = new Map() + // full per-position metrics (not just value): the pool-group headers roll + // these up. Snapshot-priced like the portfolio totals above — cards refine + // with the live feed where they have one. + const clM = new Map() const v2Val = new Map() let lpValue = 0 let unpriced = 0 @@ -113,7 +123,7 @@ export function PositionsTab() { const t1 = tokAll[p.pool.token1.toLowerCase()] if (!t0 || !t1) { unpriced++ - clVal.set(p, null) + clM.set(p, null) continue } const m = clPosMetrics({ @@ -127,7 +137,7 @@ export function PositionsTab() { upUsd, wethUsd, }) - clVal.set(p, m.valueUsd) + clM.set(p, m) if (m.valueUsd === null) unpriced++ else lpValue += m.valueUsd + (m.feesUsd ?? 0) if (m.feesUsd) feesUsdTotal += m.feesUsd @@ -149,13 +159,19 @@ export function PositionsTab() { if (m.staked?.kind === 'emissions') upPerDayTotal += m.staked.upPerDay } const pendingUpUsd = upUsd !== undefined ? (Number(pendingUp) / 1e18) * upUsd : null + // rewards = claimable UP emissions + uncollected fees: positions with no + // emissions at all (uniswap, unstaked up33) still surface their harvest here + const rewardsUsd = (pendingUpUsd ?? 0) + feesUsdTotal - // display order: staked first, then up33 wallet, then uniswap — biggest first - const rank = (p: ClPosition) => (p.staked ? 0 : p.pool.protocol === 'up33' ? 1 : 2) + // Protocol and value define the order; staking/unstaking must not move a card. const clSorted = [...data.cl].sort( - (a, b) => rank(a) - rank(b) || (clVal.get(b) ?? -1) - (clVal.get(a) ?? -1), + (a, b) => compareClPositionDisplay(a, b, clM.get(a)?.valueUsd ?? null, clM.get(b)?.valueUsd ?? null), ) const v2Sorted = [...data.v2].sort((a, b) => (v2Val.get(b) ?? -1) - (v2Val.get(a) ?? -1)) + // bucket same-pool CL positions (multiple NFTs, staked + wallet) under one + // expandable header; single-position pools stay flat. v2 is one card per + // pool already, so it never groups. + const clGroups = groupByPool(clSorted) // a range order is SUPPOSED to sit out of range — don't count it as an anomaly const outOfRange = data.cl.filter((x) => { if (x.pool.protocol === 'up33' && limitTagOf(x.tokenId)) return false @@ -224,6 +240,33 @@ export function PositionsTab() {
) + // PENDING REWARDS breakdown, in harvest order: UP emissions · uncollected + // fees · accrual rate · claim action. Assembled as parts so the separator + // never dangles (fees-only wallets, unclaimable-but-accruing edge cases). + const rewardsParts: ReactNode[] = [] + if (pendingUp > 0n) + rewardsParts.push( + + {fmtAmount(pendingUp, 18)} UP + {pendingUpUsd !== null && pendingUpUsd > 0.01 ? ` ≈ ${fmtUsd(pendingUpUsd)}` : ''} + , + ) + if (feesUsdTotal > 0.01) + rewardsParts.push({t('pos.lpValueFees', { usd: fmtUsd(feesUsdTotal) })}) + if (upPerDayTotal > 0) + rewardsParts.push( + + {t('pos.upPerDay', { n: fmtNum(upPerDayTotal, 3) })} + {upUsd !== undefined ? ` ${t('pos.upPerDayUsd', { usd: fmtUsd(upPerDayTotal * upUsd) })}` : ''} + , + ) + if (claimables.count > 0) + rewardsParts.push( + + {t('pos.claimAll', { n: claimables.count })} + , + ) + return (
@@ -242,39 +285,33 @@ export function PositionsTab() { cl: data.cl.length, v2: data.v2.length, staked: data.cl.filter((p) => p.staked).length, - })}${feesUsdTotal > 0.01 ? ` · ${t('pos.lpValueFees', { usd: fmtUsd(feesUsdTotal) })}` : ''}${ - unpriced > 0 ? ` · ${t('pos.lpValueUnpriced', { n: unpriced })}` : '' - }`} + })}${unpriced > 0 ? ` · ${t('pos.lpValueUnpriced', { n: unpriced })}` : ''}`} /> - 0n ? 'green' : ''}> - {fmtAmount(pendingUp, 18)} - {pendingUpUsd !== null && pendingUp > 0n && ( - ≈ {fmtUsd(pendingUpUsd)} - )} - - + rewardsUsd > 0.01 ? ( + + {fmtUsd(rewardsUsd)} + + ) : pendingUp > 0n ? ( + // UP accrued but unpriceable — show the raw amount rather than $0 + + {fmtAmount(pendingUp, 18)} UP + + ) : ( + '—' + ) } sub={ - <> - {upPerDayTotal > 0 && ( - - {t('pos.upPerDay', { n: fmtNum(upPerDayTotal, 3) })} - {upUsd !== undefined ? ` ${t('pos.upPerDayUsd', { usd: fmtUsd(upPerDayTotal * upUsd) })}` : ''} - {claimables.count > 0 ? ' · ' : ''} - - )} - {claimables.count > 0 ? ( - - {t('pos.claimAll', { n: claimables.count })} - - ) : upPerDayTotal > 0 ? null : ( - t('pos.nothingClaimable') - )} - + rewardsParts.length > 0 + ? rewardsParts.map((el, i) => ( + + {i > 0 && ' · '} + {el} + + )) + : t('pos.nothingClaimable') } /> {t('pos.noClCta')}
)} - {clSorted.map((p) => ( - // tokenIds are only unique per NPM — prefix the protocol in the key - - ))} + {clGroups.map((g) => + g.length === 1 ? ( + // tokenIds are only unique per NPM — prefix the protocol in the key + + ) : ( + clM.get(p)} + data={pools.data!} + xtokens={data.tokens} + user={user} + liveOf={liveOf} + statOf={statOf} + upUsd={upUsd} + wethUsd={wethUsd} + /> + ), + )}
{t('pos.sectionV2', { n: data.v2.length })}
{data.v2.length === 0 && (
@@ -334,6 +386,7 @@ export function PositionsTab() { key={p.pool.address} pos={p} data={pools.data!} + xtokens={data.tokens} user={user} stat={statOf(p.pool)} upUsd={upUsd} @@ -355,6 +408,7 @@ export function ClCard({ stat, upUsd, wethUsd, + nested, }: { pos: ClPosition data: PoolsData @@ -364,6 +418,9 @@ export function ClCard({ stat?: PoolStat upUsd?: number wethUsd?: number | null + /** rendered inside a ClPoolGroup: the pair title / protocol / pool-type + badges live in the group head, so drop them from the card head here */ + nested?: boolean }) { const { t } = useTranslation() const t0 = xtokens[pos.pool.token0.toLowerCase()] ?? tokenOf(data, pos.pool.token0) @@ -422,35 +479,7 @@ export function ClCard({ const stake = () => run(async () => { - if (!pos.pool.gauge) return - const approved = await readContract(wagmiConfig, { - abi: clPmAbi, - address: npm, - functionName: 'getApproved', - args: [pos.tokenId], - chainId: CHAIN_ID, - }) - if (approved.toLowerCase() !== pos.pool.gauge.toLowerCase()) { - const ok = await step(t('pos.stApproveNft', { id: pos.tokenId.toString() }), () => - writeContract(wagmiConfig, { - abi: clPmAbi, - address: npm, - functionName: 'approve', - args: [pos.pool.gauge!, pos.tokenId], - chainId: CHAIN_ID, - }), - ) - if (!ok) return - } - await step(t('pos.stStake', { id: pos.tokenId.toString() }), () => - writeContract(wagmiConfig, { - abi: clGaugeAbi, - address: pos.pool.gauge!, - functionName: 'deposit', - args: [pos.tokenId], - chainId: CHAIN_ID, - }), - ) + if (pos.pool.gauge) await stakeClNft(pos.pool.gauge, npm, pos.tokenId) }) // CLGauge.withdraw auto-claims accrued UP, so the swap offer applies here too @@ -553,13 +582,23 @@ export function ClCard({ return (
- - {t0.symbol}/{t1.symbol} - - - - CL {(pos.pool.feePpm / 10_000).toFixed(2)}% · ts{pos.pool.tickSpacing} - + {!nested && ( + <> + + + + + CL {(pos.pool.feePpm / 10_000).toFixed(2)}% · ts{pos.pool.tickSpacing} + + + )}
+
+ + {fmtUsd(m.valueUsd)} + + ) : ( + {t('pos.noAnchor')} + ) + } + subs={[ + + + {fmtAmount(held.amount0, t0.decimals)} {t0.symbol} + + , + + + {fmtAmount(held.amount1, t1.decimals)} {t1.symbol} + + , + ]} + /> + {pos.staked ? ( + + {fmtAmount(pos.earned, 18)} UP + + } + subs={ + upUsd !== undefined && pos.earned > 0n + ? [≈ {fmtUsd((Number(pos.earned) / 1e18) * upUsd)}] + : undefined + } + /> + ) : ( + 0.01 ? ( + ≈ {fmtUsd(m.feesUsd)} + ) : ( + {hasFees ? '…' : '—'} + ) + } + subs={ + hasFees + ? [ + `${fmtAmount(pos.fees0, t0.decimals)} ${t0.symbol}`, + `${fmtAmount(pos.fees1, t1.decimals)} ${t1.symbol}`, + ] + : undefined + } + /> + )} + {!limitTag && } +
+ )} -
- - {t('pos.value')} - {m.valueUsd !== null ? ( - - {fmtUsd(m.valueUsd)} - - ) : ( - {t('pos.noAnchor')} - )} - - - {t('pos.holds')} - - - {fmtAmount(held.amount0, t0.decimals)} {t0.symbol} - - {' '} - +{' '} - - - {fmtAmount(held.amount1, t1.decimals)} {t1.symbol} - - - - {pos.staked ? ( - - {t('pos.pendingUpK')} - - - {fmtAmount(pos.earned, 18)} - {upUsd !== undefined && pos.earned > 0n && ( - ≈ {fmtUsd((Number(pos.earned) / 1e18) * upUsd)} - )} - - - - ) : ( - - {t('pos.fees')} - {fmtAmount(pos.fees0, t0.decimals)} {t0.symbol} + {fmtAmount(pos.fees1, t1.decimals)} {t1.symbol} - {m.feesUsd !== null && m.feesUsd > 0.01 && ≈ {fmtUsd(m.feesUsd)}} - {pos.pool.protocol === 'up33' && {t('pos.levyNote')}} - - )} -
- {!limitTag && ( -
- - {t('pos.earning')} - - -
- )} - {panel === 'inc' && !pos.staked && ( () + for (const p of list) { + const k = p.pool.address.toLowerCase() + const g = m.get(k) + if (g) g.push(p) + else m.set(k, [p]) + } + return [...m.values()] +} + +type ClAgg = { + value: number | null // Σ underlying USD, excl fees — matches each card's "value" + feesUsd: number + pendingUp: bigint + upPerDay: number // emissions UP/day + usdPerDay: number // emissions $ + fee $ (only what we can price) + aprPct: number | null // value-weighted blend of every position's daily yield + stakedSharePct: number // Σ staked liquidity / this pool's staked liquidity + stakedCount: number + count: number +} + +/** roll a same-pool group up to the numbers its header shows */ +function aggregateClGroup( + positions: ClPosition[], + metricsOf: (p: ClPosition) => ClMetrics | null | undefined, +): ClAgg { + let value: number | null = null + let feesUsd = 0 + let pendingUp = 0n + let upPerDay = 0 + let usdPerDay = 0 + let stakedLiq = 0 + let stakedCount = 0 + for (const p of positions) { + pendingUp += p.earned + if (p.staked) { + stakedCount++ + stakedLiq += Number(p.liquidity) + } + const m = metricsOf(p) + if (!m) continue + if (m.valueUsd !== null) value = (value ?? 0) + m.valueUsd + if (m.feesUsd) feesUsd += m.feesUsd + if (m.earning.kind === 'emissions') { + upPerDay += m.earning.upPerDay + if (m.earning.usdPerDay !== null) usdPerDay += m.earning.usdPerDay + } else if (m.earning.kind === 'fees') { + usdPerDay += m.earning.usdPerDay + } + } + const denom = Number(positions[0].pool.stakedLiquidity) + return { + value, + feesUsd, + pendingUp, + upPerDay, + usdPerDay, + aprPct: value !== null && value > 0 ? ((usdPerDay * 365) / value) * 100 : null, + stakedSharePct: denom > 0 ? (stakedLiq / denom) * 100 : 0, + stakedCount, + count: positions.length, + } +} + +/** aggregated earning cell for a pool group (mirrors EarnCell vocabulary) */ +function ClAggEarnCell({ agg, label }: { agg: ClAgg; label: string }) { + const { t } = useTranslation() + if (agg.upPerDay <= 0 && agg.usdPerDay <= 0) + return {t('pos.groupIdle')}} /> + const emit = agg.upPerDay > 0 ? t('pos.earnEmit', { n: fmtNum(agg.upPerDay, 3) }) : null + const subs: ReactNode[] = [] + if (agg.aprPct !== null && emit) subs.push(emit) + if (agg.usdPerDay > 0) subs.push(t('pos.earnUsdDay', { usd: fmtUsd(agg.usdPerDay) })) + return ( + + + {agg.aprPct !== null ? {t('pos.earnApr', { apr: fmtApr(agg.aprPct) })} : emit} + + } + subs={subs} + /> + ) +} + +/** expandable header over 2+ positions in one pool (default expanded). The + aggregate is computed from metricsOf; the child cards refine themselves. */ +export function ClPoolGroup(props: { + positions: ClPosition[] + metricsOf: (p: ClPosition) => ClMetrics | null | undefined + data: PoolsData + xtokens: Record + user: `0x${string}` + liveOf: (p: ClPosition) => LiveSlot0 | undefined + statOf: (pool: Pool) => PoolStat | undefined + upUsd?: number + wethUsd?: number | null +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + const { positions, data, xtokens } = props + const agg = aggregateClGroup(positions, props.metricsOf) + const pool = positions[0].pool + const t0 = xtokens[pool.token0.toLowerCase()] ?? tokenOf(data, pool.token0) + const t1 = xtokens[pool.token1.toLowerCase()] ?? tokenOf(data, pool.token1) + const share = (s: number) => (s < 0.01 ? '<0.01%' : s.toFixed(2) + '%') + return ( +
+
{ + if (window.getSelection()?.toString()) return // a drag-select is a copy, not a toggle + setOpen(!open) + }} + > +
+ {open ? '▾' : '▸'} + + + + + CL {(pool.feePpm / 10_000).toFixed(2)}% · ts{pool.tickSpacing} + + + {t('pos.groupPositions', { n: agg.count })} + {agg.stakedCount > 0 ? ` · ${t('pos.groupStakedN', { n: agg.stakedCount })}` : ''} + +
+
+ + {fmtUsd(agg.value)} + + ) : ( + {t('pos.noAnchor')} + ) + } + subs={ + agg.feesUsd > 0.01 + ? [{t('pos.lpValueFees', { usd: fmtUsd(agg.feesUsd) })}] + : undefined + } + /> + {agg.pendingUp > 0n && ( + ≈ {fmtUsd((Number(agg.pendingUp) / 1e18) * props.upUsd)}] + : undefined + } + /> + )} + + {agg.stakedSharePct > 0 && } +
+
+ {open && ( +
+ {positions.map((p) => ( + + ))} +
+ )} +
+ ) +} + export function IncreasePanel(props: { pos: ClPosition npm: Address // position manager the NFT lives in (protocol-resolved) @@ -749,7 +993,7 @@ export function IncreasePanel(props: { }) { const { pos, dec0, dec1, sqrtP } = props const { t } = useTranslation() - const [fund, setFund] = useState<'pair' | 'zap'>('pair') + const [fund, setFund] = useState<'pair' | 'zap'>('zap') const [a0, setA0] = useState('') const [a1, setA1] = useState('') const bal = useBalances(props.user, [pos.pool.token0, pos.pool.token1]) @@ -935,6 +1179,7 @@ export function IncreasePanel(props: { export function V2Card({ pos, data, + xtokens = {}, user, stat, upUsd, @@ -942,14 +1187,16 @@ export function V2Card({ }: { pos: V2Position data: PoolsData + /** metadata for pair tokens outside the UP33 registry (univ2 pairs) */ + xtokens?: Record user: `0x${string}` stat?: PoolStat upUsd?: number wethUsd?: number | null }) { const { t } = useTranslation() - const t0 = tokenOf(data, pos.pool.token0) - const t1 = tokenOf(data, pos.pool.token1) + const t0 = xtokens[pos.pool.token0.toLowerCase()] ?? tokenOf(data, pos.pool.token0) + const t1 = xtokens[pos.pool.token1.toLowerCase()] ?? tokenOf(data, pos.pool.token1) const [busy, setBusy] = useState(false) const [removeOpen, setRemoveOpen] = useState(false) const m = v2PosMetrics({ pos, dec0: t0.decimals, dec1: t1.decimals, stat, upUsd, wethUsd }) @@ -965,17 +1212,8 @@ export function V2Card({ const stakeAll = () => run(async () => { - if (!pos.pool.gauge || pos.walletLp === 0n) return - if (!(await ensureAllowance(pos.pool.address, user, pos.pool.gauge, pos.walletLp, 'LP'))) return - await step(t('pos.stStakeLp', { pair: `${t0.symbol}/${t1.symbol}` }), () => - writeContract(wagmiConfig, { - abi: v2GaugeAbi, - address: pos.pool.gauge!, - functionName: 'deposit', - args: [pos.walletLp], - chainId: CHAIN_ID, - }), - ) + if (pos.pool.gauge) + await stakeV2Lp(pos.pool.address, pos.pool.gauge, pos.walletLp, user, `${t0.symbol}/${t1.symbol}`) }) const unstakeAll = () => @@ -1026,6 +1264,32 @@ export function V2Card({ txlog.push('err', t('pos.stNoWalletLp')) return } + if (pos.pool.protocol === 'univ2') { + // the vanilla router has no quoteRemoveLiquidity — mins come from the + // pro-rata reserve share, same tolerance as the up33 path + const expect0 = (lp * pos.pool.reserve0) / pos.pool.totalSupply + const expect1 = (lp * pos.pool.reserve1) / pos.pool.totalSupply + if (!(await ensureAllowance(pos.pool.address, user, UNI.V2_ROUTER, lp, 'LP'))) return + await step(t('pos.stRemoveLp', { pct, pair: `${t0.symbol}/${t1.symbol}` }), () => + writeContract(wagmiConfig, { + abi: uniV2RouterAbi, + address: UNI.V2_ROUTER, + functionName: 'removeLiquidity', + args: [ + pos.pool.token0, + pos.pool.token1, + lp, + applySlippage(expect0, SLIP_BPS), + applySlippage(expect1, SLIP_BPS), + user, + deadline(), + ], + chainId: CHAIN_ID, + }), + ) + setRemoveOpen(false) + return + } const quote = await readContract(wagmiConfig, { abi: v2RouterAbi, address: ADDR.V2_ROUTER, @@ -1061,11 +1325,20 @@ export function V2Card({ return (
-
- - {t('pos.value')} - {m.valueUsd !== null ? {fmtUsd(m.valueUsd)} : {t('pos.noAnchor')}} - - - {t('pos.v2Total')} - {fmtAmount(pos.amount0, t0.decimals)} {t0.symbol} + {fmtAmount(pos.amount1, t1.decimals)} {t1.symbol} - - - {t('pos.v2WalletLp')} - {fmtAmount(pos.walletLp, 18)} - - - {t('pos.v2StakedLp')} - 0n ? 'green' : ''}>{fmtAmount(pos.stakedLp, 18)} - +
+ {fmtUsd(m.valueUsd)} : {t('pos.noAnchor')}} + subs={[ + `${fmtAmount(pos.amount0, t0.decimals)} ${t0.symbol}`, + `${fmtAmount(pos.amount1, t1.decimals)} ${t1.symbol}`, + ]} + /> + {pos.earned > 0n && ( - - {t('pos.pendingUpK')} - - {fmtAmount(pos.earned, 18)} - {upUsd !== undefined && ≈ {fmtUsd((Number(pos.earned) / 1e18) * upUsd)}} - - + ≈ {fmtUsd((Number(pos.earned) / 1e18) * upUsd)}] + : undefined + } + /> )} {hasFees && ( - - {t('pos.v2Claimable')} - {fmtAmount(pos.claimable0, t0.decimals)} {t0.symbol} + {fmtAmount(pos.claimable1, t1.decimals)} {t1.symbol} - {m.feesUsd !== null && m.feesUsd > 0.01 && ≈ {fmtUsd(m.feesUsd)}} - + 0.01 ? ( + ≈ {fmtUsd(m.feesUsd)} + ) : ( + + ) + } + subs={[ + `${fmtAmount(pos.claimable0, t0.decimals)} ${t0.symbol}`, + `${fmtAmount(pos.claimable1, t1.decimals)} ${t1.symbol}`, + ]} + /> )} + {m.staked && } + {m.wallet && }
- {(m.staked || m.wallet) && ( -
- {m.staked && ( - - {m.wallet ? t('pos.earningStaked') : t('pos.earning')} - - - )} - {m.wallet && ( - - {m.staked ? t('pos.earningWallet') : t('pos.earning')} - - - )} -
- )} {removeOpen && (
@@ -1172,46 +1442,93 @@ export function V2Card({ // ---------------- helpers ---------------- -/** one-line "what is this position earning right now" renderer */ -function EarnLine({ e, v2 }: { e: Earning; v2?: boolean }) { +/** one metrics cell: small uppercase label, prominent primary value, dim + * stacked sub-lines. The card body and group header both speak this. */ +function PCell(props: { k: ReactNode; tip?: string; v: ReactNode; subs?: ReactNode[] }) { + return ( +
+ + {props.k} + + {props.v} + {props.subs?.map((s, i) => ( + + {s} + + ))} +
+ ) +} + +/** "what is this position earning right now" as a cell: primary = APR (or the + * colored idle/stalled state), subs = accrual rates. Color carries STATE only + * (green dot = actively earning, red = stalled, amber = paused); the + * pool-share detail lives in the label tooltip. */ +function EarnCell({ e, v2, label }: { e: Earning; v2?: boolean; label: string }) { const { t } = useTranslation() const share = (s: number) => (s < 0.01 ? '<0.01%' : s.toFixed(2) + '%') switch (e.kind) { - case 'emissions': + case 'emissions': { + const emit = t('pos.earnEmit', { n: fmtNum(e.upPerDay, 3) }) + const subs: ReactNode[] = e.aprPct !== null ? [emit] : [] + if (e.usdPerDay !== null) subs.push(t('pos.earnUsdDay', { usd: fmtUsd(e.usdPerDay) })) return ( - - {t('pos.earnEmit', { n: fmtNum(e.upPerDay, 3) })} - {e.usdPerDay !== null ? ` ${t('pos.earnUsdDay', { usd: fmtUsd(e.usdPerDay) })}` : ''} - {e.aprPct !== null ? ` · ${t('pos.earnApr', { apr: fmtApr(e.aprPct) })}` : ''} ·{' '} - {v2 - ? t('pos.earnShareGauge', { share: share(e.sharePct) }) - : t('pos.earnShareStaked', { share: share(e.sharePct) })} - + + + {e.aprPct !== null ? {t('pos.earnApr', { apr: fmtApr(e.aprPct) })} : emit} + + } + subs={subs} + /> ) + } case 'emissions-idle': - return e.reason === 'out-of-range' ? ( - - {t('pos.earnOutStaked')} - - ) : ( - {t('pos.earnEnded')} - ) - case 'fees': return ( - - {t('pos.earnFeeApr')} {fmtApr(e.aprPct)} {t('pos.earnUsdDay', { usd: fmtUsd(e.usdPerDay) })} ·{' '} - {v2 - ? t('pos.earnSharePool', { share: share(e.sharePct) }) - : t('pos.earnShareActive', { share: share(e.sharePct) })} - {!v2 && <> · {t('pos.earnWhileInRange')}} - + ● {t('pos.earnOutStaked')} + ) : ( + ● {t('pos.earnEnded')} + ) + } + /> ) + case 'fees': { + const subs: ReactNode[] = [t('pos.earnUsdDay', { usd: fmtUsd(e.usdPerDay) })] + if (!v2) subs.push(t('pos.earnWhileInRange')) + return ( + + + + {t('pos.earnFeeApr')} {fmtApr(e.aprPct)} + + + } + subs={subs} + /> + ) + } case 'fees-unknown': - return {t('pos.earnUnknown')} + return {t('pos.earnUnknown')}} /> case 'out-of-range': - return {t('pos.earnOut')} + return ● {t('pos.earnOut')}} /> case 'empty': - return {t('pos.earnEmpty')} + return {t('pos.earnEmpty')}} /> } } diff --git a/src/components/tabs/SwapTab.tsx b/src/components/tabs/SwapTab.tsx index 5d0ed38..60517a0 100644 --- a/src/components/tabs/SwapTab.tsx +++ b/src/components/tabs/SwapTab.tsx @@ -1,52 +1,100 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' +import { useConnectModal } from '@rainbow-me/rainbowkit' 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 { writeContract } from 'wagmi/actions' +import { formatUnits, parseUnits, type Address, type Hex, type ReplacementReason } from 'viem' +import { wethAbi } from '../../abi' +import { ADDR, CHAIN_ID, EXPLORER } 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 { usePendingSwaps } from '../../hooks/usePendingSwaps' +import { DIRECT_QUOTE_REFRESH_MS, useDirectQuote, useSolverQuote } from '../../hooks/useQuotes' import { useTokenList } from '../../hooks/useTokenList' +import { useTokenUsd } from '../../hooks/useTokenUsd' +import { applySlippage } from '../../lib/clmath' +import { + directRouteLabel, + type DirectCandidate, + type DirectProtocol, +} from '../../lib/directSwap' +import { bpsDiff, fmtAmount, fmtNum, fmtUsd, sanitizeAmountInput, shortAddr } from '../../lib/format' +import { isSameUnresolvedSwap, isSettled, pendingSwaps } from '../../lib/pendingSwaps' +import { legSharePct, solverRouteLabel, venueLabel } from '../../lib/solver' +import { selectUsableQuoteData, solverQuoteNeedsManualRefresh } from '../../lib/solverRefresh' +import { executeSolverSwap, executeSwap, SlippageError } from '../../lib/swapExec' +import { swapSubmissionKey, swapSubmissions } from '../../lib/swapSubmissions' +import { + allInCostBps, + autoSlippage, + retrySlippage, + slippagePctToBps, + slippageTone, + SLIPPAGE_CHOICES, + type AutoSlippage, +} from '../../lib/swapGate' +import { peekSwapIntent, takeSwapIntent } from '../../lib/swapIntent' +import { step } from '../../lib/tx' +import { txlog } from '../../lib/txlog' import type { TokenInfo } from '../../types' +import { Flash } from '../Flash' import { TokenSelect } from '../TokenSelect' -import { Badge, Btn } from '../ui' +import { Badge, Btn, NumInput } from '../ui' import { LimitPanel } from './LimitPanel' const ETH_GAS_BUFFER = parseUnits('0.001', 18) type SwapMode = 'market' | 'limit' +type SlippageChoice = 'auto' | number // bps; a preset chip or the typed custom value +type SwapSource = 'solver' | DirectProtocol + +// Token choices survive top-level tab unmounts, but intentionally not reloads. +let rememberedTokenIn: TokenInfo | null = null +let rememberedTokenOut: TokenInfo | null = null + +function routeDetail(candidate: DirectCandidate): string { + const label = directRouteLabel(candidate.route) + if (candidate.route.protocol === 'up33') return `${label} · ts${candidate.route.tickSpacing}` + return label +} export function SwapTab() { const { t } = useTranslation() const { address: user } = useAccount() - const list = useTokenList() + const { openConnectModal } = useConnectModal() + const tokenList = useTokenList() + const list = tokenList.tokens const [mode, setModeState] = useState(() => (location.hash === '#limit' ? 'limit' : 'market')) - const [tIn, setTIn] = useState(null) - const [tOut, setTOut] = useState(null) + const [tIn, setTInState] = useState(rememberedTokenIn) + const [tOut, setTOutState] = useState(rememberedTokenOut) const [amtStr, setAmtStr] = useState('') const [amount, setAmount] = useState(0n) - const [slip, setSlip] = useState(50) - const [override, setOverride] = useState<'kyber' | 'native' | null>(null) + const [slippageChoice, setSlippageChoice] = useState('auto') + const [customSlip, setCustomSlip] = useState('') // raw text of the typed-% field + const [slipOpen, setSlipOpen] = useState(false) + const [override, setOverride] = useState(null) + // AUTO floor after a slippage halt: never re-offer the tolerance that just + // failed. Cleared when the trade context changes or a swap fills. + const [slipFloor, setSlipFloor] = useState(null) const [invRate, setInvRate] = useState(false) - const [busy, setBusy] = useState(false) - - const setMode = (m: SwapMode) => { - setModeState(m) - history.replaceState(null, '', m === 'limit' ? '#limit' : '#swap') + const [, setClockTick] = useState(0) + const now = Date.now() + const setTIn = (token: TokenInfo | null) => { + rememberedTokenIn = token + setTInState(token) + } + const setTOut = (token: TokenInfo | null) => { + rememberedTokenOut = token + setTOutState(token) + } + + const setMode = (next: SwapMode) => { + setModeState(next) + history.replaceState(null, '', next === 'limit' ? '#limit' : '#swap') } - // deep links: #limit / #swap navigation while this tab is already mounted useEffect(() => { const onHash = () => { if (location.hash === '#limit') setModeState('limit') @@ -56,82 +104,149 @@ export function SwapTab() { 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 (!list.length) return + const intent = peekSwapIntent() + if (intent) { + const input = list.find((token) => token.address.toLowerCase() === intent.tokenIn.toLowerCase()) + const output = list.find((token) => token.address.toLowerCase() === intent.tokenOut.toLowerCase()) + if (input && output) { + takeSwapIntent() + setModeState('market') + setTIn(input) + setTOut(output) + setAmtStr(formatUnits(intent.amount, input.decimals)) + return + } + } + if (!tIn) setTIn(list.find((token) => token.native) ?? list[0]) if (!tOut) { - const up = list.find((t) => t.address.toLowerCase() === ADDR.UP.toLowerCase()) + const up = list.find((token) => token.address.toLowerCase() === ADDR.UP.toLowerCase()) if (up) setTOut(up) } }, [list, tIn, tOut]) - // debounce amount parsing useEffect(() => { - const h = setTimeout(() => { + const handle = setTimeout(() => { try { setAmount(tIn ? parseUnits(amtStr === '' ? '0' : amtStr, tIn.decimals) : 0n) } catch { setAmount(0n) } }, 350) - return () => clearTimeout(h) + return () => clearTimeout(handle) }, [amtStr, tIn]) - useEffect(() => setOverride(null), [tIn?.address, tOut?.address, amount]) + useEffect(() => { + setOverride(null) + setSlipFloor(null) + }, [tIn?.address, tOut?.address, amount]) - const bal = useBalances(user, [tIn?.address, tOut?.address].filter(Boolean) as Address[]) + // latest trade identity — an in-flight swap that fails must not write its + // retry floor onto a trade the user has since changed to + const tradeSig = `${tIn?.address ?? ''}|${tOut?.address ?? ''}|${amount}` + const tradeSigRef = useRef(tradeSig) + tradeSigRef.current = tradeSig + const submissionKey = user && tIn && tOut && amount > 0n + ? swapSubmissionKey(user, tIn.address, tOut.address, amount) + : null + const submitting = useSyncExternalStore( + swapSubmissions.subscribe, + () => !!submissionKey && swapSubmissions.has(submissionKey), + () => false, + ) + // persisted in-flight swaps (survive reload) — the prominent, reload-proof + // pending display, and the guard against re-submitting a trade already in flight + const allPending = usePendingSwaps() + const pending = user + ? allPending.filter((entry) => entry.account.toLowerCase() === user.toLowerCase()) + : [] + const tradePending = !!user && !!tIn && !!tOut && pending.some((entry) => + isSameUnresolvedSwap(entry, user, tIn.address, tOut.address, amount), + ) + + const balances = 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 quote = useDirectQuote(tIn?.address, tOut?.address, amount) + const solver = useSolverQuote(tIn?.address, tOut?.address, amount) + // per-unit USD prices for both sides (display only — quotes stay token-denominated) + const usdIn = useTokenUsd(tIn) + const usdOut = useTokenUsd(tOut) + // Invalid or stale data leaves the selection boundary once; no downstream + // card, ranking, execution, or cost calculation can accidentally reuse it. + const { solver: solverData, direct: directData, hasStaleData } = selectUsableQuoteData({ + solverData: solver.data, + directData: quote.data, + solverError: solver.isError, + directError: quote.isError, + solverDataUpdatedAt: solver.dataUpdatedAt, + directDataUpdatedAt: quote.dataUpdatedAt, + now, + }) + const solverNeedsManualRefresh = solverQuoteNeedsManualRefresh( + solver.autoRefreshExhausted, + solverData != null, + ) + const bestDirect = directData?.best ?? null + const automaticProtocol = bestDirect?.route.protocol ?? null + // the solver routes across every venue with splits, so it wins whenever it + // answers; the direct venues stay live as comparison rows and fallback + const automaticSource: SwapSource | null = + solverData && (!bestDirect || solverData.quote.amountOutNet >= bestDirect.amountOut) + ? 'solver' + : automaticProtocol + const selectedSource = override ?? automaticSource + const solverSel = selectedSource === 'solver' ? solverData : null + const selected = + selectedSource && selectedSource !== 'solver' ? directData?.byProtocol[selectedSource] ?? null : null + // one shape for the details section, whichever kind is selected + const outAmount = solverSel ? solverSel.quote.amountOutNet : selected?.amountOut ?? null + const selImpactBps = solverSel ? solverSel.impactBps : selected?.impactBps ?? null + const selVenueFeeBps = solverSel ? solverSel.venueFeeBps : selected ? selected.route.feePpm / 100 : null + const autoBase = + selImpactBps == null || selVenueFeeBps == null ? null : autoSlippage(selImpactBps, selVenueFeeBps) + // a failed run IS a volatility measurement: floor AUTO at the retry tolerance + const flooredBps = slipFloor === null ? null : Math.max(autoBase?.bps ?? 0, slipFloor) + const automaticSlippage: AutoSlippage | null = + flooredBps === null ? autoBase : { bps: flooredBps, tone: slippageTone(flooredBps) } + const effectiveSlippage = slippageChoice === 'auto' ? automaticSlippage?.bps : slippageChoice + const customInvalid = customSlip !== '' && slippagePctToBps(customSlip) === null + // terminal fee: the solver reports its server-side rate in the response; + // direct routes charge the client-side configured rate + const terminalFeeBps = solverSel ? solverSel.quote.feeBps : ENV.swapFeeBps + // ONE fee-free denominator for every usable row. Prefer the broader solver; + // direct fallback is exact only when its probe winner is V2. + const midOut = solverData?.quote.midAmountOut ?? directData?.midOut ?? null + // headline COST = price move + pool fees + transfer taxes + terminal fee + const totalCostBps = outAmount == null ? null : allInCostBps(outAmount, midOut) + // Quote cards carry the SAME all-in number as the details COST row above. + const solverCardCostBps = solverData ? allInCostBps(solverData.quote.amountOutNet, midOut) : null - 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 + // 1s ticker driving the quote-refresh countdown (only while a quote is live) + const ticking = mode === 'market' && amount > 0n && !isWrap && !isUnwrap + useEffect(() => { + if (!ticking) return + const tick = () => setClockTick((version) => version + 1) + const id = setInterval(tick, 1000) + window.addEventListener('focus', tick) + document.addEventListener('visibilitychange', tick) + return () => { + clearInterval(id) + window.removeEventListener('focus', tick) + document.removeEventListener('visibilitychange', tick) + } + }, [ticking]) const flip = () => { - const a = tIn setTIn(tOut) - setTOut(a) + setTOut(tIn) setAmtStr('') } - const run = async (fn: () => Promise) => { - setBusy(true) - try { - await fn() - } finally { - setBusy(false) - } - } + const run = (fn: () => Promise) => + submissionKey ? swapSubmissions.run(submissionKey, fn) : Promise.resolve(false) const doWrap = () => run(() => @@ -145,6 +260,7 @@ export function SwapTab() { }), ), ) + const doUnwrap = () => run(() => step(t('swap.stUnwrap', { amt: amtStr }), () => @@ -160,98 +276,88 @@ export function SwapTab() { 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, + if (!user || !tIn || !tOut || amount === 0n || effectiveSlippage === undefined) return + // register a prominent pending entry the instant the tx is broadcast; it + // persists across reload, so a user who reloads still sees it in flight + // (and can't re-submit the same trade — the root of the double-swap) + const onSubmitted = (hash: Hex) => { + const persisted = pendingSwaps.add({ + id: hash, + account: user, + tokenIn: tIn.address, + tokenOut: tOut.address, + amountIn: amount.toString(), + inSym: tIn.symbol, + outSym: tOut.symbol, + amountInDisp: amtStr, + createdAt: Date.now(), + status: 'pending', + }) + if (!persisted) txlog.push('err', t('swap.pendingSaveFailed'), hash) + } + const onReplaced = (oldHash: Hex, newHash: Hex, reason: ReplacementReason) => { + const persisted = pendingSwaps.replaceHash(oldHash, newHash, reason !== 'repriced') + if (!persisted) txlog.push('err', t('swap.pendingSaveFailed'), newHash) + } + try { + let result: Awaited> = null + if (solverSel) { + result = await executeSolverSwap({ + tokenIn: tIn.address, + tokenOut: tOut.address, + amountIn: amount, + slippageBps: effectiveSlippage, + minimumAmountOut: applySlippage(solverSel.quote.amountOutNet, effectiveSlippage), sender: user, recipient: user, - slippageBps: slip, - amountIn: amount, - nativeIn: isNative(tIn.address), + inputSymbol: tIn.symbol, + label: t('swap.stSwapSolver', { amt: amtStr, a: tIn.symbol, b: tOut.symbol }), + onSubmitted, + onReplaced, }) - } 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 (selected) { + result = await executeSwap({ + route: selected.route, + tokenIn: tIn.address, + tokenOut: tOut.address, + amountIn: amount, + minimumAmountOut: applySlippage(selected.amountOut, effectiveSlippage), + sender: user, + recipient: user, + inputSymbol: tIn.symbol, + label: t('swap.stSwapDirect', { + amt: amtStr, + a: tIn.symbol, + b: tOut.symbol, + route: directRouteLabel(selected.route), }), - ) - } 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, - }), - ) + onSubmitted, + onReplaced, + }) + } else return + // flip the pending row to confirmed at once on the happy path — don't + // leave it (and the "SWAP PENDING" button) waiting on the poller's next + // tick; usePendingSwaps still settles reloaded/reverted/stale entries + if (result) pendingSwaps.settle(result.receipt.transactionHash, 'confirmed') + // ignore a settlement whose trade the user has already moved past + if (result && tradeSigRef.current === tradeSig) setSlipFloor(null) // a fill clears the floor + } catch (error) { + // slippage halt: re-quote both venues so the shown output/min reflect + // live prices, and raise AUTO so the retry is not offered the tolerance + // that just failed — mirrors the ZAP panel's halt handling. Skip if the + // trade moved on, and only ever raise the floor (never lower it). + if (error instanceof SlippageError && tradeSigRef.current === tradeSig) { + setSlipFloor((prev) => Math.max(prev ?? 0, retrySlippage(effectiveSlippage))) + void quote.refetch() + void solver.refetch() } + txlog.push('err', (error as Error).message) } }) const modeRow = (
- {t('swap.mode')} + {t('swap.mode')} @@ -262,248 +368,618 @@ export function SwapTab() { > {t('swap.limit')} + + + Robinhood Chain +
) - if (mode === 'limit') + if (mode === 'limit') { return (
{modeRow}
) + } - if (!tIn || !tOut) + const tokenListNotices = ( + <> + {tokenList.uniswapSource === 'fallback' && ( +
{t('swap.tokenListFallback')}
+ )} + {tokenList.uniswapError && ( +
+ {t('swap.tokenListFailed', { err: tokenList.uniswapError.message.slice(0, 90) })} +
+ )} + {tokenList.up33Error && ( +
+ {t('swap.up33TokenListFailed', { err: tokenList.up33Error.message.slice(0, 90) })} +
+ )} + + ) + + if (!tIn || !tOut) { return ( -
+
{modeRow} + {tokenListNotices}
{t('swap.loadingTokens')}
) - - 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 balIn = balances.data?.[tIn.address.toLowerCase()] + const balOut = balances.data?.[tOut.address.toLowerCase()] + const insufficient = balIn !== undefined && amount > balIn + // gas headroom only gates MAX — a partial percentage already leaves the + // rest for gas, and reserving it there zeroed out small balances entirely + const setPct = (pct: bigint) => { + if (balIn === undefined) return + const buffer = pct === 100n && tIn.native ? ETH_GAS_BUFFER : 0n + const spendable = balIn > buffer ? balIn - buffer : 0n + setAmtStr(formatUnits((spendable * pct) / 100n, tIn.decimals)) + } const rate = - selOut !== undefined && amount > 0n - ? Number(formatUnits(selOut, tOut.decimals)) / Number(formatUnits(amount, tIn.decimals)) + outAmount != null && amount > 0n + ? Number(formatUnits(outAmount, tOut.decimals)) / + Number(formatUnits(amount, tIn.decimals)) : undefined + const selectedRoute = solverSel + ? solverRouteLabel(solverSel.quote) + : selected + ? directRouteLabel(selected.route) + : '' + let outputAmount = '0.0' + if (isWrap || isUnwrap) outputAmount = amtStr || '0.0' + else if (outAmount != null) outputAmount = fmtAmount(outAmount, tOut.decimals) + else if (quote.isFetching || solver.isFetching) outputAmount = '…' + // full-precision output drives the direction flash, so a price move on a + // narrow-mover still flashes even when the rounded display looks unchanged + const outFlashV = + !isWrap && !isUnwrap && outAmount != null ? Number(formatUnits(outAmount, tOut.decimals)) : undefined - const usdIn = kyber.data?.routeSummary.amountInUsd - const usdOut = kyber.data?.routeSummary.amountOutUsd + // USD notionals at per-unit spot (impact is shown separately on each quote) + const usdValue = (raw: bigint, decimals: number, unit: number | undefined) => + unit === undefined ? undefined : Number(formatUnits(raw, decimals)) * unit + const amtNum = Number(amtStr) + const inUsd = usdIn.data !== undefined && Number.isFinite(amtNum) && amtNum > 0 ? amtNum * usdIn.data : undefined + let outUsd: number | undefined + if (isWrap || isUnwrap) outUsd = usdOut.data !== undefined && Number.isFinite(amtNum) && amtNum > 0 ? amtNum * usdOut.data : undefined + else if (outAmount != null && amount > 0n) outUsd = usdValue(outAmount, tOut.decimals, usdOut.data) + // buy-side USD vs sell-side USD at venue spot — surfaces total cost (impact + fees) + const usdDelta = + !isWrap && !isUnwrap && outAmount != null && amount > 0n && inUsd !== undefined && outUsd !== undefined && inUsd > 0 + ? ((outUsd - inUsd) / inUsd) * 100 + : undefined + const minReceived = + outAmount != null && effectiveSlippage !== undefined ? applySlippage(outAmount, effectiveSlippage) : null + // best across BOTH kinds — the shortfall chips on non-best rows measure against it + const bestNet = + automaticSource === 'solver' && solverData ? solverData.quote.amountOutNet : bestDirect?.amountOut ?? null + const nextIn = directData && quote.dataUpdatedAt + ? Math.max(0, Math.ceil((quote.dataUpdatedAt + DIRECT_QUOTE_REFRESH_MS - now) / 1000)) + : null - return ( -
- {modeRow} -
-
- {t('swap.from')} - - { - const v = e.target.value.replace(',', '.') - if (v === '' || /^\d*\.?\d*$/.test(v)) setAmtStr(v) - }} - /> -
-
- - {t('common.bal')} {balIn !== undefined ? fmtAmount(balIn, tIn.decimals) : '—'}{' '} - - {insufficient && {t('common.insufficient')}} - - {amount > 0n && usdIn ? `≈ ${fmtUsd(usdIn)}` : ''} -
+ const protocolRows: { protocol: DirectProtocol; label: string }[] = [ + { protocol: 'uniswap', label: t('swap.uniswapDirect') }, + { protocol: 'up33', label: t('swap.up33Direct') }, + ] + + // slippage summary line (the expandable DETAILS row) + const isAutoSlip = slippageChoice === 'auto' && customSlip === '' + const slipForced = amount > 0n && outAmount != null && effectiveSlippage === undefined && !customInvalid + const slipExpanded = slipOpen || slipForced || customInvalid + let slipValue: string + let slipTone = '' + if (customInvalid) { + slipValue = t('common.slipInvalid') + slipTone = 'red' + } else if (effectiveSlippage !== undefined) { + slipValue = `${isAutoSlip ? 'AUTO · ' : ''}${effectiveSlippage / 100}%` + slipTone = slippageTone(effectiveSlippage) + } else { + slipValue = t('swap.chooseSlippage') + slipTone = amount > 0n && selected ? 'red' : 'dim' + } + + const sellCard = ( +
+
+ {t('swap.sell')} + {balIn !== undefined && ( + + )}
+
+ + { + const value = sanitizeAmountInput(event.target.value, tIn.decimals) + if (value !== null) setAmtStr(value) + }} + /> +
+
+ + {balIn !== undefined && + balIn > 0n && + [25n, 50n, 75n, 100n].map((pct) => ( + + ))} + + {inUsd !== undefined && <>≈ {fmtUsd(inUsd)}} +
+
+ ) -
-
+ {buyCard} + + ) -
-
- {t('swap.to')} - - - {isWrap || isUnwrap - ? amtStr || '0.0' - : selOut !== undefined - ? fmtAmount(selOut, tOut.decimals) - : kyber.isFetching || native.isFetching - ? '…' - : '0.0'} - -
-
- - {t('common.bal')} {balOut !== undefined ? fmtAmount(balOut, tOut.decimals) : '—'} - - {amount > 0n && usdOut && !isWrap && !isUnwrap ? `≈ ${fmtUsd(usdOut)}` : ''} + // reload-proof pending display: unlike the corner activity log (in-memory), + // these entries survive a reload, so an in-flight swap stays visible and the + // user isn't tempted to fire it again. Settled by usePendingSwaps' poller. + const pendingBanner = pending.length > 0 && ( + + ) + + // ---- wrap / unwrap: fee-less 1:1, no routes or slippage ---- + if (isWrap || isUnwrap) { + let cta = isWrap ? t('swap.wrapBtn', { amt: amtStr || '0' }) : t('swap.unwrapBtn', { amt: amtStr || '0' }) + if (!user) cta = t('common.connectWallet') + else if (amount === 0n) cta = t('swap.ctaEnterAmount') + else if (insufficient) cta = t('common.insufficientBalance') + return ( +
+ {modeRow} +
+ {tokenListNotices} + {cards} +
+
+ {t('swap.kRate')} + + + 1 {tIn.symbol} = 1 {tOut.symbol} + +
+
+ {t('swap.kFee')} + + {t('swap.wrapNote', { addr: shortAddr(ADDR.WETH) })} +
+
+ {pendingBanner} +
+ openConnectModal?.() : isWrap ? doWrap : doUnwrap} + > + {cta} + +
+ ) + } - {rate !== undefined && !isWrap && !isUnwrap && ( -
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, - })} -
- )} + // ---- market swap ---- + const quotesNeedRefresh = outAmount == null && (solverNeedsManualRefresh || hasStaleData) + const refreshQuotes = () => { + void quote.refetch() + void solver.refetch() + } + let cta = t('swap.noRoute') + let ctaDisabled = true + let refreshCta = false + if (!user) { + cta = t('common.connectWallet') + ctaDisabled = false + } else if (amount === 0n) cta = t('swap.ctaEnterAmount') + else if (insufficient) cta = t('common.insufficientBalance') + else if (tradePending) cta = t('swap.ctaPending') // this exact trade is in flight — block a double + else if (quotesNeedRefresh) { + cta = t('swap.ctaRefreshQuote') + ctaDisabled = false + refreshCta = true + } else if (outAmount == null && (quote.isFetching || solver.isFetching)) cta = t('swap.ctaQuoting') + else if (outAmount != null && effectiveSlippage === undefined) cta = t('swap.chooseSlippage') + else if (outAmount != null) { + cta = t('swap.execVia', { route: solverSel ? t('swap.solverRoute') : selectedRoute }) + ctaDisabled = false + } - {isWrap || isUnwrap ? ( -
- - {insufficient - ? t('common.insufficientBalance') - : isWrap - ? t('swap.wrapBtn', { amt: amtStr || '0' }) - : t('swap.unwrapBtn', { amt: amtStr || '0' })} - - {t('swap.wrapNote', { addr: shortAddr(ADDR.WETH) })} -
- ) : ( - <> -
{t('swap.quotes')}
- {amount === 0n &&
{t('swap.enterAmount')}
} - {amount > 0n && ( - <> -
setOverride('kyber')}> - - {sel === 'kyber' ? '◉' : '○'} {t('swap.kyberAgg')} - - {kyber.isFetching && !kyber.data ? ( - - ) : kyber.isError ? ( - - {t('swap.unavailable', { err: (kyber.error as Error)?.message?.slice(0, 60) })} - - ) : kyberOut !== undefined ? ( - <> - - {fmtAmount(kyberOut, tOut.decimals)} {tOut.symbol} - - {t('swap.gas', { usd: fmtUsd(kyber.data!.routeSummary.gasUsd) || '—' })} - {routeBreakdown(kyber.data!.routeSummary)} - {auto === 'kyber' && {t('swap.best')}} - - ) : ( - - )} + return ( +
+ {modeRow} +
+ {tokenListNotices} + {cards} + + {amount > 0n && ( + <> +
+ {t('swap.route')} + +
+ {quote.isError && ( +
+ {t('swap.unavailable', { err: (quote.error as Error).message.slice(0, 90) })}
-
setOverride('native')}> - - {sel === 'native' ? '◉' : '○'} {t('swap.up33Native')} - - {native.isFetching && !native.data ? ( - - ) : nativeBest ? ( - <> - - {fmtAmount(nativeBest.amountOut, tOut.decimals)} {tOut.symbol} + )} + {(() => { + const q = solverData?.quote ?? null + const isSel = selectedSource === 'solver' + const behindBest = + q && bestNet !== null && automaticSource !== 'solver' ? bpsDiff(q.amountOutNet, bestNet) / 100 : null + const qUsd = q ? usdValue(q.amountOutNet, tOut.decimals, usdOut.data) : undefined + return ( +
setOverride('solver')}> +
+ + {isSel ? '◉' : '○'} {t('swap.solverRoute')} - - {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, - })} - - {kyberOut !== undefined && ( - - {t('swap.bpsVsKyber', { - bps: - (bpsDiff(nativeBest.amountOut, kyberOut) >= 0 ? '+' : '') + - bpsDiff(nativeBest.amountOut, kyberOut).toFixed(1), - })} - + {solver.isError ? ( + {t('swap.solverDown')} + ) : q ? null : solver.isFetching ? ( + + ) : ( + )} - {auto === 'native' && {t('swap.best')}} - {isNative(tIn.address) && {t('swap.needsWeth')}} - - ) : ( - {t('swap.noNativePool')} - )} -
+ {q && ( + <> + + {fmtAmount(q.amountOutNet, tOut.decimals)} {tOut.symbol} + + {automaticSource === 'solver' ? ( + {t('swap.best')} + ) : ( + behindBest !== null && + Math.abs(behindBest) >= 0.005 && ( + + {behindBest > 0 ? '+' : ''} + {behindBest.toFixed(2)}% + + ) + )} + + )} +
+ {q && ( +
+
+ {q.route.map((leg, i) => ( + + ))} +
+
+ {q.route.slice(0, 4).map((leg, i) => ( + + {legSharePct(leg.shareBps)}%{' '} + {leg.hops.map((hop) => venueLabel(hop.protocol)).join(' → ')} + + ))} + {q.route.length > 4 && +{q.route.length - 4}} +
+
+ )} + {q && solverData && (solverCardCostBps !== null || qUsd !== undefined) && ( +
+ + {solverCardCostBps !== null && ( + + {t('swap.quoteCost', { pct: (solverCardCostBps / 100).toFixed(2) })} + + )} + + {qUsd !== undefined && ≈ {fmtUsd(qUsd)}} +
+ )} +
+ ) + })()} + {protocolRows.map(({ protocol, label }) => { + const candidate = directData?.byProtocol[protocol] ?? null + const status = directData?.status[protocol] + const isSel = selectedSource === protocol + let state: JSX.Element | null = {t('swap.noDirectPool')} + if (quote.isError) state = + else if (quote.isFetching && !directData) state = + else if (protocol === 'up33' && tokenList.up33Loading) state = + else if (status === 'failed') state = {t('swap.quoteFailed')} + else if (candidate) state = null + const candidateUsd = candidate ? usdValue(candidate.amountOut, tOut.decimals, usdOut.data) : undefined + // same baseline as the solver card, so the rows compare like for like + const candidateCostBps = candidate ? allInCostBps(candidate.amountOut, midOut) : null + // shortfall vs the best route, in the reference-UI style (-1.76%) + const behindBest = + candidate && bestNet !== null && automaticSource !== protocol + ? bpsDiff(candidate.amountOut, bestNet) / 100 + : null + return ( +
setOverride(protocol)}> +
+ + {isSel ? '◉' : '○'} {label} + + {state} + {candidate && ( + <> + + {fmtAmount(candidate.amountOut, tOut.decimals)} {tOut.symbol} + + {automaticSource === protocol && !tokenList.up33Loading ? ( + {t('swap.best')} + ) : ( + behindBest !== null && + Math.abs(behindBest) >= 0.005 && ( + + {behindBest > 0 ? '+' : ''} + {behindBest.toFixed(2)}% + + ) + )} + + )} +
+ {candidate && ( +
+ + {routeDetail(candidate)} + {candidateCostBps !== null && ( + <> + {/* the separator stays outside, so the dashed rule + underlines the number and not the bullet */} + {' · '} + + {t('swap.quoteCost', { pct: (candidateCostBps / 100).toFixed(2) })} + + + )} + + {candidateUsd !== undefined && ≈ {fmtUsd(candidateUsd)}} +
+ )} +
+ ) + })} -
- {t('swap.slippage')} - {[10, 50, 100].map((b) => ( - - ))} - -
-
- - {!user - ? t('common.connectWallet') - : insufficient - ? t('common.insufficientBalance') - : sel - ? t('swap.execVia', { route: sel === 'kyber' ? 'KYBER' : 'UP33' }) - : t('swap.noRoute')} - - {selOut !== undefined && ( - - {t('swap.minReceived', { - amt: fmtAmount(applySlippage(selOut, slip), tOut.decimals), - sym: tOut.symbol, - slip: slip / 100, +
{t('swap.details')}
+
+ {rate !== undefined && ( +
setInvRate(!invRate)} title={t('swap.rateTip')}> + {t('swap.kRate')} + + + {t('swap.rateV', { + a: invRate ? tOut.symbol : tIn.symbol, + n: fmtNum(invRate ? 1 / rate : rate), + b: invRate ? tIn.symbol : tOut.symbol, })} + {(invRate ? usdOut : usdIn).data !== undefined && ( + · ≈ {fmtUsd((invRate ? usdOut : usdIn).data)} + )} - )} - {sel === 'native' && isNative(tOut.address) && ( - {t('swap.nativeWethNote')} - )} +
+ )} +
setSlipOpen(!slipOpen)}> + {t('swap.slippage')} + + + {slipValue} {slipExpanded ? '▴' : '▾'} +
- - )} - - )} + {slipExpanded && ( +
+ + {SLIPPAGE_CHOICES.map((bps) => ( + + ))} + { + setCustomSlip(v) + setSlippageChoice(slippagePctToBps(v) ?? 'auto') + }} + placeholder={t('common.slipCustom')} + width={80} + invalid={customInvalid} + /> + % + {customInvalid && {t('common.slipInvalid')}} +
+ )} + {minReceived !== null && effectiveSlippage !== undefined && ( +
+ {t('swap.kMinReceived')} + + + {fmtAmount(minReceived, tOut.decimals)} {tOut.symbol} + {usdOut.data !== undefined && ( + · ≈ {fmtUsd(usdValue(minReceived, tOut.decimals, usdOut.data))} + )} + +
+ )} + {/* ONE number. It used to expand into price move + pool fee + + terminal fee, which never summed to it and cannot be made to: + the pool a blended route pays more fee to can also carry the + better mid, so the leftover "price move" goes negative. The + terminal fee stays on its own row as disclosure, not as an + addend — whose cut this is matters even at 0.00%. */} + {outAmount != null && ( + <> +
+ + {t('swap.kCost')} + + + + {totalCostBps === null ? '—' : `${(totalCostBps / 100).toFixed(2)}%`} + +
+
+ {t('swap.kTerminalFee')} + + {(terminalFeeBps / 100).toFixed(2)}% +
+ + )} +
+ + )} + + {pendingBanner} +
+ openConnectModal?.() : refreshCta ? refreshQuotes : doSwap} + > + {cta} + +
+
) } - diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 2280e50..e02990c 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -1,18 +1,18 @@ import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { formatUnits } from 'viem' -import { fmtAmount } from '../lib/format' +import { fmtAmount, sanitizeAmountInput } from '../lib/format' export function Btn(props: { onClick?: () => void disabled?: boolean - tone?: 'default' | 'danger' | 'ghost' + tone?: 'default' | 'danger' | 'ghost' | 'amber' big?: boolean busy?: boolean title?: string children: ReactNode }) { - const cls = ['btn', props.tone === 'danger' ? 'danger' : '', props.tone === 'ghost' ? 'ghost' : '', props.big ? 'big' : ''] + const cls = ['btn', props.tone === 'default' ? '' : props.tone, props.big ? 'big' : ''] .filter(Boolean) .join(' ') return ( @@ -78,10 +78,13 @@ export function NumInput(props: { placeholder?: string disabled?: boolean width?: number + invalid?: boolean + /** clamp typed fraction digits (token precision); 18 = EVM max */ + decimals?: number }) { return ( { - const v = e.target.value.replace(',', '.') - if (v === '' || /^\d*\.?\d*$/.test(v)) props.onChange(v) + const v = sanitizeAmountInput(e.target.value, props.decimals ?? 18) + if (v !== null) props.onChange(v) }} /> ) diff --git a/src/config/addresses.ts b/src/config/addresses.ts index fdfffc7..fca87ba 100644 --- a/src/config/addresses.ts +++ b/src/config/addresses.ts @@ -1,6 +1,8 @@ import type { Address } from 'viem' -// All addresses verified against Blockscout's verified source (compiler 0.8.19). +export const NATIVE = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as Address + +// All addresses verified on Blockscout — see docs/up33-contract-map.md at repo root. export const ADDR = { UP: '0x57C0E45cB534413D1C20A4240955d6bB250BB4F1', VE_UP: '0x5d321dE36F0bf98D92b291280514F3878582B7B6', @@ -27,6 +29,8 @@ export const ADDR = { export const UNI = { V3_FACTORY: '0x1f7d7550B1b028f7571E69A784071F0205FD2EfA', V3_NPM: '0x73991a25C818Bf1f1128dEAaB1492D45638DE0D3', + V3_QUOTER: '0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7', + V3_SWAP_ROUTER: '0xcaf681a66d020601342297493863e78c959e5cb2', V2_FACTORY: '0x8bcEaA40B9AcdfAedF85AdF4FF01F5Ad6517937f', V2_ROUTER: '0x89e5DB8B5aA49aA85AC63f691524311AEB649eba', } as const satisfies Record diff --git a/src/config/bridge.ts b/src/config/bridge.ts new file mode 100644 index 0000000..a6fc3df --- /dev/null +++ b/src/config/bridge.ts @@ -0,0 +1,96 @@ +// Bridge domain config: which remote chains the BRIDGE tab offers. Robinhood +// Chain is always one side of every transfer; a "remote" is the other. WHICH +// tokens can travel is NOT configured here — it is discovered live from each +// engine's own support surface (lib/bridge/tokens.ts), same-token routes only. +// Facts (routes, costs, provider behavior) are documented in docs/bridge-research.md. +import type { Address, Chain } from 'viem' +import { arbitrum, base, mainnet, optimism } from 'wagmi/chains' +import { CHAIN_ID, EXPLORER } from './addresses' +import type { BridgeProviderId } from '../lib/bridge/types' + +/** zero address = native-currency sentinel understood by Relay, Across and the canonical bridge */ +export const NATIVE_SENTINEL = '0x0000000000000000000000000000000000000000' as Address + +export type RemoteChain = { + chain: Chain + label: string +} + +export const REMOTE_CHAINS: RemoteChain[] = [ + { chain: mainnet, label: 'ETHEREUM' }, + { chain: arbitrum, label: 'ARBITRUM' }, + { chain: base, label: 'BASE' }, + { chain: optimism, label: 'OPTIMISM' }, +] + +export function remoteById(id: number): RemoteChain | null { + return REMOTE_CHAINS.find((r) => r.chain.id === id) ?? null +} + +export function explorerOf(chainId: number): string { + if (chainId === CHAIN_ID) return EXPLORER + return remoteById(chainId)?.chain.blockExplorers?.default.url ?? EXPLORER +} + +/** 'in' = deposit onto Robinhood, 'out' = withdraw from Robinhood */ +export type BridgeDir = 'in' | 'out' + +/** one discovered same-token route between Robinhood and the selected remote. + * `providers` is already resolved for the direction the UI is showing. */ +export type BridgeTokenOption = { + symbol: string + /** identical on both sides — discovery drops mismatched-decimals pairs */ + decimals: number + /** address on the Robinhood side (NATIVE_SENTINEL = native ETH) */ + robinhoodToken: Address + /** address on the remote side (NATIVE_SENTINEL = native ETH) */ + remoteToken: Address + providers: BridgeProviderId[] +} + +export type BridgeIntent = { + dir: BridgeDir + token: BridgeTokenOption + remote: RemoteChain + /** origin-side amount in origin token units */ + amount: bigint +} + +export type ResolvedIntent = { + originChainId: number + destChainId: number + inputToken: Address // NATIVE_SENTINEL for native ETH + outputToken: Address + inputSymbol: string + outputSymbol: string + inputDecimals: number + outputDecimals: number +} + +/** map a user intent onto the provider-facing origin/destination legs + * (same-token model: both legs share symbol and decimals) */ +export function resolveIntent(i: BridgeIntent): ResolvedIntent { + const remoteId = i.remote.chain.id + return { + originChainId: i.dir === 'in' ? remoteId : CHAIN_ID, + destChainId: i.dir === 'in' ? CHAIN_ID : remoteId, + inputToken: i.dir === 'in' ? i.token.remoteToken : i.token.robinhoodToken, + outputToken: i.dir === 'in' ? i.token.robinhoodToken : i.token.remoteToken, + inputSymbol: i.token.symbol, + outputSymbol: i.token.symbol, + inputDecimals: i.token.decimals, + outputDecimals: i.token.decimals, + } +} + +/** Robinhood Chain's canonical (Arbitrum) bridge Inbox on Ethereum L1. + * Source: l2beat discovery, cross-checked on-chain 2026-07-18: + * Inbox.bridge() == 0xDf8755334ce7A73cCF6b581C02eA649AE3E864b3 (the chain's + * Bridge, which emitted every verified real deposit's MessageDelivered). */ +export const PORTAL_INBOX = '0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D' as Address +export const PORTAL_PARENT_CHAIN_ID: number = mainnet.id + +/** measured delivery latency of real depositEth transfers (484–689s over 3 + * samples, 2026-07-18) — quote ~10 min, not the nominal "5 min" */ +export const PORTAL_ETA_SEC = 600 + diff --git a/src/config/env.ts b/src/config/env.ts index 0e71151..555e747 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -9,30 +9,66 @@ 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. + // (the reverse 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. + // reverse proxy — see README "Deploy"): 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 + // same-origin proxy mode: when the kyber base is a path, third-party data + // APIs (Kyber, DexScreener, Goldsky) route through the site's own // proxies too — users behind restrictive networks keep every feature, and the // browser only ever talks to our origin + the chain RPC + wallet relays. + // Default deploys build WITHOUT this (browser-direct, per-user IPs & rate + // limits); enabling it also requires matching data-proxy blocks in whatever + // reverse proxy fronts the site. 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, + // Swap-solver API: cross-venue split routing + ready-to-sign Settler txs + // Public URL, CORS-open — the browser calls it directly. Point this at your + // own instance via VITE_SOLVER_URL. + solverUrl: ((import.meta.env.VITE_SOLVER_URL ?? '').trim() || 'https://solver.lp-terminal.xyz').replace(/\/+$/, ''), // 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(), + // Swap terminal fee — dropped to 0 (2026-07-19). The UI keeps the fee row + // and shows 0.00%; direct routes then settle through the routers' no-fee + // sweep variants (the *WithFee functions revert on feeBips = 0), and the + // solver charges its own server-side rate (response feeBps is the truth). + swapFeeBps: 0, + // Zap keeps the 9 bps output fee on its embedded swap leg. + zapFeeBps: 9, + // Bridge fee. Non-zero rides the providers' native integrator-fee params + // (Relay appFees takes bps as a string, Across appFee a decimal fraction); + // 0 = free — the quote requests then omit the fee params entirely. + bridgeFeeBps: 0, + terminalFeeReceiver: (import.meta.env.KYBERSWAP_FEE_RECEIVER ?? '').trim(), +} + +export function swapFee(): { bps: number; receiver: Address } { + if (!ENV.terminalFeeReceiver) throw new Error('terminal fee receiver is not configured') + return { bps: ENV.swapFeeBps, receiver: getAddress(ENV.terminalFeeReceiver) } +} + +/** zap's embedded swap leg keeps charging; same receiver, separate rate */ +export function zapFee(): { bps: number; receiver: Address } { + if (!ENV.terminalFeeReceiver) throw new Error('terminal fee receiver is not configured') + return { bps: ENV.zapFeeBps, receiver: getAddress(ENV.terminalFeeReceiver) } +} + +/** same receiver as the swap fee; separate rate. Fail-closed like swapFee. */ +export function bridgeFee(): { bps: number; receiver: Address } { + if (!ENV.terminalFeeReceiver) throw new Error('terminal fee receiver is not configured') + return { bps: ENV.bridgeFeeBps, receiver: getAddress(ENV.terminalFeeReceiver) } +} + +/** Alchemy app key extracted from an Alchemy RPC url (any network subdomain), + * null for other providers. One key serves every network via per-network + * subdomains, so the bridge-chain transports can derive theirs from the same + * secret the robinhood transport already uses. */ +export function alchemyKeyOf(url: string): string | null { + const m = /^https:\/\/[a-z0-9-]+\.g\.alchemy\.com\/v2\/([^/?#]+)/i.exec(url) + return m ? m[1] : null } diff --git a/src/config/wagmi.ts b/src/config/wagmi.ts index 930e88f..6a5819f 100644 --- a/src/config/wagmi.ts +++ b/src/config/wagmi.ts @@ -1,8 +1,9 @@ import { getDefaultConfig } from '@rainbow-me/rainbowkit' import { fallback, http } from 'wagmi' +import { arbitrum, base, mainnet, optimism } from 'wagmi/chains' import { customRpc } from '../lib/rpcPref' import { robinhood } from './chain' -import { ENV, PUBLIC_RPC } from './env' +import { alchemyKeyOf, ENV, PUBLIC_RPC } from './env' // Read-transport resolution (one build works in every deployment): // - user-set custom RPC (footer control, localStorage) -> always wins @@ -20,10 +21,59 @@ const transport = userRpc ? fallback([http('/rpc', { batch: true }), http(PUBLIC_RPC, { batch: true })]) : http(PUBLIC_RPC, { batch: true }) +// Robinhood is home; the extra chains exist only as BRIDGE counterparties +// (origin-side sends + balance reads). Each mirrors the robinhood transport's +// three-tier resolution, per network: +// - personal build whose RPC override is an Alchemy url -> the SAME key's +// per-network subdomain leads (one commercial quota for every chain) +// - server build -> same-origin /rpc/ nginx proxy (key stays +// server-side; plain static hosting just fails over to the next tier) +// - key-free CORS-open public nodes always bring up the rear (each endpoint +// reachability-tested — a lone default RPC proved blocked from some +// networks and silently blanked origin balances) +const alchemyKey = alchemyKeyOf(ENV.rpcUrl) +const remoteTransport = (alchemyNet: string, proxyPath: string, publics: string[]) => { + const urls = alchemyKey + ? [`https://${alchemyNet}.g.alchemy.com/v2/${alchemyKey}`, ...publics] + : import.meta.env.PROD + ? [proxyPath, ...publics] + : publics + return fallback(urls.map((u) => http(u, { batch: true }))) +} + export const wagmiConfig = getDefaultConfig({ appName: 'UP33 Terminal', projectId: ENV.wcProjectId, - chains: [robinhood], - transports: { [robinhood.id]: transport }, + chains: [robinhood, mainnet, arbitrum, base, optimism], + transports: { + [robinhood.id]: transport, + [mainnet.id]: remoteTransport('eth-mainnet', '/rpc/eth', [ + 'https://ethereum-rpc.publicnode.com', + 'https://eth.drpc.org', + 'https://eth.merkle.io', + ]), + [arbitrum.id]: remoteTransport('arb-mainnet', '/rpc/arb', [ + 'https://arb1.arbitrum.io/rpc', + 'https://arbitrum-one-rpc.publicnode.com', + ]), + [base.id]: remoteTransport('base-mainnet', '/rpc/base', [ + 'https://mainnet.base.org', + 'https://base-rpc.publicnode.com', + ]), + [optimism.id]: remoteTransport('opt-mainnet', '/rpc/op', [ + 'https://mainnet.optimism.io', + 'https://optimism-rpc.publicnode.com', + ]), + }, ssr: false, }) + +/** a chain id this wagmi config can actually serve (bridge steps come from + * provider APIs as plain numbers — validate before handing them to wagmi) */ +export type ConfiguredChainId = (typeof wagmiConfig)['chains'][number]['id'] + +export function asConfiguredChain(id: number): ConfiguredChainId { + const known = wagmiConfig.chains.find((c) => c.id === id) + if (!known) throw new Error(`chain ${id} is not configured in this terminal`) + return known.id +} diff --git a/src/content/changelog.ts b/src/content/changelog.ts new file mode 100644 index 0000000..78ec323 --- /dev/null +++ b/src/content/changelog.ts @@ -0,0 +1,123 @@ +// The WHAT'S NEW feed. This is the ONE file to edit when shipping something a +// user would notice — the header button reads it directly, and an entry lights +// the red dot while it is younger than two days (see src/lib/news.ts). +// +// House rules: +// - newest first; `id` is stable and never reused (it is the React key) +// - `date` is the ship date, ISO YYYY-MM-DD, parsed as UTC +// - write from the user's side: what changed for them, never how it works. +// No baselines, no engines, no milliseconds, no bps — "0.09%", not "9 bps" +// - internal work never appears here: quote-scoring changes, the indexer, +// refactors, deploys. But a user-visible OUTCOME does belong — "you get a +// better price now" is fair game; naming the engine that did it is not +// - don't advertise a fee level. Fees move, and the feed is not the place +// users should be reading them off +// - both languages are required, so half a translation can't ship + +export type NewsTag = 'new' | 'fix' | 'perf' + +/** one string, both locales — the popover picks by the active language */ +export type Bilingual = { en: string; zh: string } + +export type NewsEntry = { + id: string + date: string + tag: NewsTag + title: Bilingual + items: Bilingual[] +} + +export const CHANGELOG: NewsEntry[] = [ + { + id: '2026-07-22-sheriff', + date: '2026-07-22', + tag: 'new', + title: { + en: 'SHEEP CHOICE now supports Sheriff, GigaDex, Sushi, and RobinSwap', + zh: 'SHEEP CHOICE 接入 Sheriff、GigaDex、Sushi 和 RobinSwap', + }, + items: [ + { + en: 'Liquidity from Sheriff, GigaDex, Sushi, and RobinSwap now joins SHEEP CHOICE routes, giving eligible swaps more paths to a better return.', + zh: 'Sheriff、GigaDex、Sushi 和 RobinSwap 的流动性现已加入 SHEEP CHOICE 路由,为符合条件的兑换提供更多更优路径。', + }, + ], + }, + { + id: '2026-07-20-state-persistence', + date: '2026-07-20', + tag: 'fix', + title: { en: 'Your settings stay put', zh: '设置不会被刷没了' }, + items: [ + { + en: 'Refresh the page — your filters, your sorting and any swap still in flight are all where you left them.', + zh: '刷新页面后,筛选、排序、以及正在进行的兑换都还在原处。', + }, + ], + }, + { + id: '2026-07-20-better-quotes', + date: '2026-07-20', + tag: 'fix', + title: { en: 'Sharper swap quotes', zh: '兑换报价更准' }, + items: [ + { + en: 'Tokens that tax their own transfers are quoted honestly now, instead of quietly coming out wrong.', + zh: '带转账税的代币现在会给出诚实的报价,不再悄悄算偏。', + }, + ], + }, + { + id: '2026-07-19-sheep-choice', + date: '2026-07-19', + tag: 'new', + title: { en: 'SHEEP CHOICE — our own swap is live', zh: 'SHEEP CHOICE 上线 —— 我们自己的兑换' }, + items: [ + { + en: 'The terminal has a swap of its own now, quoting right alongside the other venues.', + zh: '终端有了自己的兑换,和其他场所并排报价。', + }, + { + en: 'It can split one trade across several pools instead of forcing it down a single path — and draws the split leg by leg.', + zh: '它能把一笔交易拆到多个池子里成交,而不是硬走一条路 —— 怎么拆的,逐段画给你看。', + }, + { + en: 'Whichever venue hands you the most is picked for you, and you can always take another yourself.', + zh: '谁给得多就自动选谁,你也可以自己挑另一条。', + }, + ], + }, + { + id: '2026-07-19-zap-any-token', + date: '2026-07-19', + tag: 'new', + title: { en: 'Add liquidity with any token', zh: '任意代币都能一键建仓' }, + items: [ + { + en: 'Holding the wrong token? Add liquidity with it anyway — whatever needs swapping is done for you.', + zh: '手里不是配对的那两种代币也没关系,直接建仓,该换的会替你换好。', + }, + { + en: 'Uniswap V2 liquidity sitting in your wallet now shows up under POSITIONS.', + zh: '钱包里的 Uniswap V2 流动性现在会显示在「仓位」里。', + }, + ], + }, + { + id: '2026-07-18-bridge-v2', + date: '2026-07-18', + tag: 'new', + title: { en: 'Bring funds in from other chains', zh: '从其他链把资金转进来' }, + items: [ + { + en: 'Three routes are priced side by side and sorted by what actually reaches you — pick the best one.', + zh: '三条跨链通道同时比价,按实际到手的金额排序,挑最多的那条即可。', + }, + { en: 'No extra fee on any of them.', zh: '任何一条都不额外收费。' }, + { + en: 'While it is on the way you get a countdown, and the terminal tells you when the funds land.', + zh: '资金在路上时有倒计时,到账后终端会告诉你。', + }, + ], + }, +] diff --git a/src/hooks/useBalances.ts b/src/hooks/useBalances.ts index b43eb42..71d66b9 100644 --- a/src/hooks/useBalances.ts +++ b/src/hooks/useBalances.ts @@ -2,11 +2,11 @@ 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' +import { CHAIN_ID, NATIVE } from '../config/addresses' /** balances for a set of tokens (NATIVE sentinel included). key = lowercase addr */ export function useBalances(user?: Address, tokens: Address[] = []) { - const pc = usePublicClient() + const pc = usePublicClient({ chainId: CHAIN_ID }) const key = tokens .map((t) => t.toLowerCase()) .sort() diff --git a/src/hooks/useBridgeBalance.ts b/src/hooks/useBridgeBalance.ts new file mode 100644 index 0000000..ba52a98 --- /dev/null +++ b/src/hooks/useBridgeBalance.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import { erc20Abi, type Address } from 'viem' +import { getBalance, readContract } from 'wagmi/actions' +import { NATIVE_SENTINEL } from '../config/bridge' +import { asConfiguredChain, wagmiConfig } from '../config/wagmi' + +/** wallet balance of one token on one configured chain (bridge origin side — + * reads go through that chain's public transport, not the wallet) */ +export function useBridgeBalance(user: Address | undefined, chainId: number, token: Address) { + return useQuery({ + queryKey: ['bridgeBal', chainId, token.toLowerCase(), user], + enabled: !!user, + refetchInterval: 15_000, + retry: false, + queryFn: async (): Promise => { + const cid = asConfiguredChain(chainId) + if (token.toLowerCase() === NATIVE_SENTINEL.toLowerCase()) { + return (await getBalance(wagmiConfig, { address: user!, chainId: cid })).value + } + return readContract(wagmiConfig, { + abi: erc20Abi, + address: token, + functionName: 'balanceOf', + args: [user!], + chainId: cid, + }) + }, + }) +} diff --git a/src/hooks/useBridgeQuotes.ts b/src/hooks/useBridgeQuotes.ts new file mode 100644 index 0000000..66d969f --- /dev/null +++ b/src/hooks/useBridgeQuotes.ts @@ -0,0 +1,63 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import type { Address } from 'viem' +import { resolveIntent, type BridgeIntent } from '../config/bridge' +import { bridgeFee } from '../config/env' +import { quoteAcross } from '../lib/bridge/across' +import { quotePortal } from '../lib/bridge/portal' +import { quoteRelay } from '../lib/bridge/relay' +import type { BridgeProviderId, BridgeQuote } from '../lib/bridge/types' + +// network providers per refresh; Relay's keyless budget is 50 quotes/min +export const BRIDGE_QUOTE_REFRESH_MS = 25_000 + +export type BridgeQuoteQueries = Record> + +/** independent per-provider queries so the fast quote renders while the slow + * one (Across composed routes can take >30s) is still working. Only providers + * the discovered token route supports are enabled; the portal "quote" is a + * local 1:1 construction (no request, no refresh needed). */ +export function useBridgeQuotes(intent: BridgeIntent | null, user?: Address): BridgeQuoteQueries { + const leg = intent ? resolveIntent(intent) : null + const on = (p: BridgeProviderId) => !!intent && intent.amount > 0n && intent.token.providers.includes(p) + const baseKey = [ + intent?.dir, + intent?.token.symbol, + intent?.remote.chain.id, + intent?.amount.toString(), + user ?? 'anon', + ] + const relay = useQuery({ + queryKey: ['bridgeQuote', 'relay', ...baseKey], + enabled: on('relay'), + refetchInterval: BRIDGE_QUOTE_REFRESH_MS, + retry: false, + queryFn: ({ signal }) => quoteRelay(leg!, intent!.amount, bridgeFee(), user ?? null, signal), + }) + const across = useQuery({ + queryKey: ['bridgeQuote', 'across', ...baseKey], + enabled: on('across'), + refetchInterval: BRIDGE_QUOTE_REFRESH_MS, + retry: false, + queryFn: ({ signal }) => quoteAcross(leg!, intent!.amount, bridgeFee(), user ?? null, signal), + }) + const portal = useQuery({ + queryKey: ['bridgeQuote', 'portal', ...baseKey], + enabled: on('portal'), + staleTime: Infinity, // lossless 1:1 — nothing to refresh + retry: false, + queryFn: () => quotePortal(leg!, intent!.amount), + }) + return { relay, across, portal } +} + +/** higher destination output wins — same-token routes pay out the same asset, + * so the canonical 1:1 quote leads whenever it is available */ +export function bestProvider(q: BridgeQuoteQueries, ids: BridgeProviderId[]): BridgeProviderId | null { + let best: BridgeProviderId | null = null + for (const id of ids) { + const d = q[id].data + if (!d) continue + if (best === null || d.outputAmount > q[best].data!.outputAmount) best = id + } + return best +} diff --git a/src/hooks/useBridgeTokens.ts b/src/hooks/useBridgeTokens.ts new file mode 100644 index 0000000..83dc47f --- /dev/null +++ b/src/hooks/useBridgeTokens.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query' +import type { RemoteChain } from '../config/bridge' +import { fetchBridgeTokens } from '../lib/bridge/tokens' + +/** discovered same-token bridge routes for one remote — support surfaces move + * rarely, so this is cached long and refetched lazily */ +export function useBridgeTokens(remote: RemoteChain) { + return useQuery({ + queryKey: ['bridgeTokens', remote.chain.id], + staleTime: 60 * 60_000, + gcTime: 4 * 60 * 60_000, + retry: 2, + queryFn: ({ signal }) => fetchBridgeTokens(remote, signal), + }) +} diff --git a/src/hooks/useEpoch.ts b/src/hooks/useEpoch.ts deleted file mode 100644 index 0cfd048..0000000 --- a/src/hooks/useEpoch.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 } -} diff --git a/src/hooks/useLiveSlot0.ts b/src/hooks/useLiveSlot0.ts index 28dc8d4..60c7ad1 100644 --- a/src/hooks/useLiveSlot0.ts +++ b/src/hooks/useLiveSlot0.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { usePublicClient } from 'wagmi' import type { Address, PublicClient } from 'viem' import { clPoolAbi } from '../abi' +import { CHAIN_ID } from '../config/addresses' export type LiveSlot0 = { sqrtPriceX96: bigint; tick: number } @@ -11,7 +12,7 @@ export type LiveSlot0 = { sqrtPriceX96: bigint; tick: number } * orders) without re-running the full pool enumeration at that rate. */ export function useLiveSlot0(pools: Address[], intervalMs = 4_000) { - const pc = usePublicClient() + const pc = usePublicClient({ chainId: CHAIN_ID }) const key = pools .map((a) => a.toLowerCase()) .sort() diff --git a/src/hooks/usePendingBridges.ts b/src/hooks/usePendingBridges.ts new file mode 100644 index 0000000..aac6eee --- /dev/null +++ b/src/hooks/usePendingBridges.ts @@ -0,0 +1,75 @@ +// Drives the conservative pending-transfer polling while the bridge tab is +// mounted: a 5s scheduler runs due checks (cadence decided in pending.ts), +// announces terminal states to the activity log, and refreshes balances on +// fill. Leaving the tab pauses polling; entries persist and resume later. +import { useEffect, useSyncExternalStore } from 'react' +import type { Hex } from 'viem' +import { getPublicClient } from 'wagmi/actions' +import { CHAIN_ID } from '../config/addresses' +import { explorerOf } from '../config/bridge' +import { asConfiguredChain, wagmiConfig } from '../config/wagmi' +import { t } from '../i18n' +import { + checkPendingTransfer, + isStale, + nextCheckAt, + pendingBridges, + type PendingTransfer, +} from '../lib/bridge/pending' +import { invalidateAll } from '../lib/tx' +import { txlog } from '../lib/txlog' + +const portalReceiptProbe = async (childTxHash: Hex): Promise => { + const client = getPublicClient(wagmiConfig, { chainId: asConfiguredChain(CHAIN_ID) }) + const rcpt = await client.getTransactionReceipt({ hash: childTxHash }).catch(() => null) + return rcpt !== null +} + +const inflight = new Set() + +function announce(t0: PendingTransfer, status: PendingTransfer['status'], fillTxHash?: string) { + if (status === 'filled') { + const id = txlog.push('ok', t('bridge.filled'), fillTxHash) + if (fillTxHash) txlog.update(id, { href: `${explorerOf(t0.destChainId)}/tx/${fillTxHash}` }) + invalidateAll() + } else if (status === 'refunded') txlog.push('err', t('bridge.refunded')) + else if (status === 'failed') txlog.push('err', t('bridge.fillFailed')) +} + +async function runCheck(entry: PendingTransfer) { + if (inflight.has(entry.id)) return + inflight.add(entry.id) + try { + const patch = await checkPendingTransfer(entry, portalReceiptProbe) + pendingBridges.update(entry.id, patch) + if (patch.status && patch.status !== entry.status) announce(entry, patch.status, patch.fillTxHash) + } finally { + inflight.delete(entry.id) + } +} + +/** manual "check now" — bypasses the conservative schedule for one entry */ +export function recheckPending(entry: PendingTransfer) { + void runCheck(entry) +} + +export function usePendingBridges(): PendingTransfer[] { + const list = useSyncExternalStore(pendingBridges.subscribe, pendingBridges.get) + useEffect(() => { + const tick = () => { + const now = Date.now() + for (const entry of pendingBridges.get()) { + if (entry.status !== 'pending') continue + if (isStale(entry, now)) { + pendingBridges.update(entry.id, { status: 'stale' }) + continue + } + if (now >= nextCheckAt(entry)) void runCheck(entry) + } + } + tick() + const id = setInterval(tick, 5_000) + return () => clearInterval(id) + }, []) + return list +} diff --git a/src/hooks/usePendingSwaps.ts b/src/hooks/usePendingSwaps.ts new file mode 100644 index 0000000..5dcf80a --- /dev/null +++ b/src/hooks/usePendingSwaps.ts @@ -0,0 +1,57 @@ +// Drives settlement of persisted pending swaps while the SWAP tab is mounted: +// pending hashes poll every 3s; old unresolved hashes stay protected as `stale` +// and continue at a lower cadence. Confirmed/failed rows linger briefly. This +// keeps reloads and slow/replaced transactions from enabling a duplicate swap. +import { useEffect, useSyncExternalStore } from 'react' +import { getPublicClient } from 'wagmi/actions' +import { CHAIN_ID } from '../config/addresses' +import { wagmiConfig } from '../config/wagmi' +import { + pendingSwapTickAction, + pendingSwaps, + type PendingSwap, +} from '../lib/pendingSwaps' +import { invalidateAll } from '../lib/tx' + +const inflight = new Set() +const lastStalePoll = new Map() + +async function settleOne(entry: PendingSwap) { + if (inflight.has(entry.id)) return + inflight.add(entry.id) + try { + const client = getPublicClient(wagmiConfig, { chainId: CHAIN_ID }) + // not-yet-mined throws — treated as "keep polling" + const rcpt = client ? await client.getTransactionReceipt({ hash: entry.id }).catch(() => null) : null + if (!rcpt) return + pendingSwaps.settle(entry.id, rcpt.status === 'success' ? 'confirmed' : 'failed') + lastStalePoll.delete(entry.id) + invalidateAll() // a fill changes balances + } finally { + inflight.delete(entry.id) + } +} + +export function usePendingSwaps(): PendingSwap[] { + const list = useSyncExternalStore(pendingSwaps.subscribe, pendingSwaps.get) + useEffect(() => { + const tick = () => { + const now = Date.now() + for (const entry of pendingSwaps.get()) { + const action = pendingSwapTickAction(entry, now, lastStalePoll.get(entry.id) ?? 0) + if (action === 'mark-stale-and-poll') pendingSwaps.settle(entry.id, 'stale') + if (action === 'poll' || action === 'mark-stale-and-poll') { + if (entry.status === 'stale' || action === 'mark-stale-and-poll') lastStalePoll.set(entry.id, now) + void settleOne(entry) + } else if (action === 'dismiss') { + lastStalePoll.delete(entry.id) + pendingSwaps.dismiss(entry.id) + } + } + } + tick() + const id = setInterval(tick, 3_000) + return () => clearInterval(id) + }, []) + return list +} diff --git a/src/hooks/usePoolStats.ts b/src/hooks/usePoolStats.ts index 2192463..7388031 100644 --- a/src/hooks/usePoolStats.ts +++ b/src/hooks/usePoolStats.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { fetchPoolStats } from '../lib/poolstats' +import { fetchDexscreener, fetchPoolStats } from '../lib/poolstats' import { usePools } from './usePools' /** 24h volume / liquidity USD per pool (dexscreener + official v2 subgraph) */ @@ -14,3 +14,21 @@ export function usePoolStats() { queryFn: () => fetchPoolStats(pools.data!.pools), }) } + +/** + * DexScreener backstop for pools the primary stats miss — GeckoTerminal only + * tracks its listed subset of the uniswap catalog (measured: 53 of the top-200 + * TVL rows), and dexscreener's CL batch skips pairs it never picked up. A pool + * that still lacks volume/liquidity after the primary merge gets one more + * chance here; pools absent from dexscreener too just keep their honest "—". + */ +export function useDsFallbackStats(addrs: string[]) { + return useQuery({ + queryKey: ['dsFallback', addrs.join(',')], + enabled: addrs.length > 0, + refetchInterval: 60_000, + staleTime: 45_000, + retry: 1, + queryFn: async () => (await fetchDexscreener(addrs)).stats, + }) +} diff --git a/src/hooks/usePools.ts b/src/hooks/usePools.ts index 6efba8d..85a67be 100644 --- a/src/hooks/usePools.ts +++ b/src/hooks/usePools.ts @@ -12,7 +12,7 @@ import { v2PoolAbi, voterAbi, } from '../abi' -import { ADDR } from '../config/addresses' +import { ADDR, CHAIN_ID } from '../config/addresses' import type { ClPool, Pool, PoolsData, TokenInfo, V2Pool } from '../types' type McRes = { status: 'success' | 'failure'; result?: unknown; error?: Error } @@ -265,7 +265,7 @@ export async function fetchPools(pc: PublicClient): Promise { } export function usePools() { - const pc = usePublicClient() + const pc = usePublicClient({ chainId: CHAIN_ID }) return useQuery({ queryKey: ['pools'], enabled: !!pc, diff --git a/src/hooks/usePositions.ts b/src/hooks/usePositions.ts index 0d84a04..5c2baf5 100644 --- a/src/hooks/usePositions.ts +++ b/src/hooks/usePositions.ts @@ -1,9 +1,10 @@ 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 { clGaugeAbi, clPmAbi, erc20Abi, uniV2PairAbi, uniV3FactoryAbi, uniV3PmAbi, uniV3PoolAbi, v2GaugeAbi, v2PoolAbi } from '../abi' +import { ADDR, CHAIN_ID, EXPLORER, UNI } from '../config/addresses' import { MAX_UINT128, getAmountsForLiquidity, getSqrtRatioAtTick } from '../lib/clmath' +import { previewV2ClaimFees } from '../lib/v2Fees' import type { ClPool, ClPosition, PoolsData, PositionsData, TokenInfo, V2Pool, V2Position } from '../types' import { usePools } from './usePools' @@ -35,6 +36,111 @@ type RawPos = readonly [ // are fee-keyed where Slipstream is tickSpacing-keyed) type RawUniPos = RawPos +/** + * Uniswap v2 wallet positions. V2 LP is a plain ERC-20 — there is no NFT + * enumeration like the NPMs, and the official factory holds 15k+ pairs + * (mostly dust), so pair-side sweeps are out. Discover from the WALLET + * instead: Blockscout lists the address's ERC-20 holdings in one call, and + * every UNI-V2 entry is verified on-chain (factory() must be the official + * deployment — a spoofed "Uniswap V2" token fails this) with balance, + * reserves and supply read fresh. Blockscout being down hides univ2 + * positions for that refresh only — same degrade contract as the univ3 + * fetch below. + */ +async function fetchUniV2Positions( + pc: PublicClient, + user: Address, +): Promise<{ v2: V2Position[]; tokens: Record }> { + const res = await fetch(`${EXPLORER}/api/v2/addresses/${user}/tokens?type=ERC-20`, { + signal: AbortSignal.timeout(10_000), + }) + if (!res.ok) throw new Error(`blockscout ${res.status}`) + type Held = { token?: { symbol?: string | null; address?: string; address_hash?: string }; value?: string } + const body = (await res.json()) as { items?: Held[] } + const pairs = [ + ...new Set( + (body.items ?? []) + .filter((it) => it.token?.symbol === 'UNI-V2' && BigInt(it.value ?? '0') > 0n) + .map((it) => (it.token?.address_hash ?? it.token?.address)?.toLowerCase()) + .filter((a): a is string => !!a), + ), + ] as Address[] + if (pairs.length === 0) return { v2: [], tokens: {} } + + const det = await mc( + pc, + pairs.flatMap((p) => [ + { abi: uniV2PairAbi, address: p, functionName: 'factory' }, + { abi: uniV2PairAbi, address: p, functionName: 'token0' }, + { abi: uniV2PairAbi, address: p, functionName: 'token1' }, + { abi: uniV2PairAbi, address: p, functionName: 'getReserves' }, + { abi: uniV2PairAbi, address: p, functionName: 'totalSupply' }, + { abi: uniV2PairAbi, address: p, functionName: 'balanceOf', args: [user] }, + ]), + ) + + const v2: V2Position[] = [] + pairs.forEach((p, j) => { + const base = j * 6 + const factory = ok
(det[base]) + const token0 = ok
(det[base + 1]) + const token1 = ok
(det[base + 2]) + const reserves = ok(det[base + 3]) + const totalSupply = ok(det[base + 4]) ?? 0n + const walletLp = ok(det[base + 5]) ?? 0n + if (factory?.toLowerCase() !== UNI.V2_FACTORY.toLowerCase()) return + if (!token0 || !token1 || !reserves || walletLp === 0n || totalSupply === 0n) return + const pool: V2Pool = { + kind: 'v2', + protocol: 'univ2', + address: p, + token0, + token1, + stable: false, + reserve0: reserves[0], + reserve1: reserves[1], + totalSupply, + gaugeTotalSupply: 0n, + feeBps: 30, // uniswap v2 flat 0.30%, rolled into reserves + gauge: null, + gaugeAlive: false, + weight: 0n, + rewardRate: 0n, + periodFinish: 0n, + } + v2.push({ + pool, + walletLp, + stakedLp: 0n, + earned: 0n, + claimable0: 0n, + claimable1: 0n, + amount0: (walletLp * reserves[0]) / totalSupply, + amount1: (walletLp * reserves[1]) / totalSupply, + }) + }) + + // erc20 metadata for pair tokens outside the UP33 registry, so any pair + // renders with real symbols/decimals + const tokens: Record = {} + const uniq = [...new Set(v2.flatMap((r) => [r.pool.token0, r.pool.token1]))] + const meta = await mc( + pc, + uniq.flatMap((a) => [ + { abi: erc20Abi, address: a, functionName: 'symbol' }, + { abi: erc20Abi, address: a, functionName: 'decimals' }, + ]), + ) + uniq.forEach((a, j) => { + tokens[a.toLowerCase()] = { + address: a, + symbol: ok(meta[j * 2]) ?? a.slice(0, 6) + '…', + decimals: ok(meta[j * 2 + 1]) ?? 18, + } + }) + return { v2, tokens } +} + /** * Uniswap v3 wallet positions (official Robinhood Chain deployment). Pools are * discovered per position via factory.getPool and read fresh (slot0/liquidity/ @@ -194,6 +300,7 @@ async function fetchPositions( ): Promise { // univ3 discovery runs concurrently with the UP33 passes below const uniP = fetchUniPositions(pc, user, pools).catch(() => ({ cl: [], tokens: {} })) + const uniV2P = fetchUniV2Positions(pc, user).catch(() => ({ v2: [], 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) @@ -366,6 +473,18 @@ async function fetchPositions( }), ) + // Solidly claimable getters only expose fees materialized by a prior pool + // interaction. Simulating claimFees as the owner includes the latest index. + await Promise.all( + v2Raw + .filter((r) => r.pool.protocol === 'up33' && r.walletLp > 0n) + .map(async (r) => { + const [fee0, fee1] = await previewV2ClaimFees(pc, r.pool.address, user, [r.claimable0, r.claimable1]) + r.claimable0 = fee0 + r.claimable1 = fee1 + }), + ) + const v2: V2Position[] = v2Raw.map((r) => { const lp = r.walletLp + r.stakedLp const ts = r.pool.totalSupply @@ -381,14 +500,14 @@ async function fetchPositions( } }) - // 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 } + const uniV2 = await uniV2P + v2.push(...uniV2.v2) + + return { cl, v2, tokens: { ...uni.tokens, ...uniV2.tokens } } } export function usePositions(user?: Address) { - const pc = usePublicClient() + const pc = usePublicClient({ chainId: CHAIN_ID }) const pools = usePools() return useQuery({ queryKey: ['positions', user], diff --git a/src/hooks/useQuotes.ts b/src/hooks/useQuotes.ts index d7f112a..cb3d191 100644 --- a/src/hooks/useQuotes.ts +++ b/src/hooks/useQuotes.ts @@ -1,117 +1,135 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' 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 { CHAIN_ID } from '../config/addresses' +import { swapFee } from '../config/env' +import { erc20Of, quoteDirectCandidates } from '../lib/directSwap' +import { fetchSolverQuote, solverVenueFeeBps, type SolverQuote } from '../lib/solver' +import { + ManualRefreshGate, + SOLVER_QUOTE_REFRESH_MS, + solverQuoteAutoRefreshExhausted, + solverQuoteCanAutoRefresh, + solverQuoteRefetchInterval, +} from '../lib/solverRefresh' 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 const DIRECT_QUOTE_REFRESH_MS = SOLVER_QUOTE_REFRESH_MS -export type NativeCandidate = - | { kind: 'v2'; pool: V2Pool; amountOut: bigint } - | { kind: 'cl'; pool: ClPool; amountOut: bigint } +type SolverQueryState = { dataUpdateCount: number; errorUpdateCount: number } +type SolverQuery = { state: SolverQueryState } +const manualRefreshes = new WeakMap() +const manualRefreshGate = new ManualRefreshGate() +const manualRefreshCount = (query: object): number => manualRefreshes.get(query) ?? 0 +const canAutoRefreshSolver = (query: SolverQuery): boolean => + solverQuoteCanAutoRefresh(query.state, manualRefreshCount(query)) -export type NativeQuote = { - best: NativeCandidate | null - candidates: NativeCandidate[] +export type SolverQuoteResult = { + quote: SolverQuote + /** solver-reported full-size rate vs its same-block 1% executable fair price */ + impactBps: number | null + /** share-weighted venue fee across the route — autoSlippage's volatility prior */ + venueFeeBps: number } -async function fetchNativeQuote( - pc: PublicClient, - pools: PoolsData, - tokenIn: Address, - tokenOut: Address, - amountIn: bigint, -): Promise { - 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() +export function useSolverQuote(tokenIn?: Address, tokenOut?: Address, amountIn?: bigint) { + const queryClient = useQueryClient() + const [, rerenderManualRefresh] = useState(0) const enabled = - !!pc && !!pools.data && !!tokenIn && !!tokenOut && !!amountIn && amountIn > 0n && + !!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!), + + const queryKey = ['solverQuote', CHAIN_ID, tokenIn, tokenOut, amountIn?.toString()] as const + const query = useQuery({ + queryKey, + enabled: (query) => enabled && canAutoRefreshSolver(query), + refetchInterval: (query) => + solverQuoteRefetchInterval(query.state, manualRefreshCount(query)), + refetchOnMount: canAutoRefreshSolver, + refetchOnReconnect: canAutoRefreshSolver, + retry: false, + queryFn: async () => { + // the slippageBps here only shapes minAmountOutNet, which display + // ignores — execution re-quotes with the user's chosen tolerance + const main = await fetchSolverQuote({ + tokenIn: tokenIn!, + tokenOut: tokenOut!, + amountIn: amountIn!, + slippageBps: 50, + }) + return { + quote: main, + impactBps: main.priceImpactBps, + venueFeeBps: solverVenueFeeBps(main), + } + }, }) + const queryRecord = queryClient.getQueryCache().find({ queryKey, exact: true }) + const manualRefetch: typeof query.refetch = async (options) => { + const record = queryClient.getQueryCache().find({ queryKey, exact: true }) + if (!record) return query.refetch(options) + + return manualRefreshGate.run(record, async () => { + const settledBefore = record.state.dataUpdateCount + record.state.errorUpdateCount + const result = await query.refetch(options) + const settledAfter = record.state.dataUpdateCount + record.state.errorUpdateCount + if (settledBefore > 0 && settledAfter > settledBefore) { + manualRefreshes.set(record, manualRefreshCount(record) + 1) + rerenderManualRefresh((version) => version + 1) + } + return result + }) + } + return { + ...query, + refetch: manualRefetch, + autoRefreshExhausted: queryRecord + ? solverQuoteAutoRefreshExhausted( + queryRecord.state, + manualRefreshCount(queryRecord), + ) + : false, + } } -export function useKyberQuote(tokenIn?: Address, tokenOut?: Address, amountIn?: bigint) { +export function useDirectQuote(tokenIn?: Address, tokenOut?: Address, amountIn?: bigint) { + const client = usePublicClient({ chainId: CHAIN_ID }) + const pools = usePools() + const up33State = pools.error ? 'up33-failed' : pools.data ? 'up33-ready' : 'up33-pending' 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()) - ) + !!client && + !!tokenIn && + !!tokenOut && + !!amountIn && + amountIn > 0n && + erc20Of(tokenIn).toLowerCase() !== erc20Of(tokenOut).toLowerCase() + return useQuery({ - queryKey: ['kyberQuote', tokenIn, tokenOut, amountIn?.toString()], + queryKey: [ + 'directQuote', + CHAIN_ID, + tokenIn, + tokenOut, + amountIn?.toString(), + up33State, + ], enabled, - refetchInterval: 15_000, - retry: 1, - queryFn: ({ signal }) => kyberRoute(tokenIn!, tokenOut!, amountIn!, { signal }), + refetchInterval: DIRECT_QUOTE_REFRESH_MS, + retry: false, + queryFn: () => { + const fee = swapFee() + return quoteDirectCandidates( + client as PublicClient, + pools.error ? null : (pools.data?.pools ?? null), + tokenIn!, + tokenOut!, + amountIn!, + fee.bps, + ) + }, }) } diff --git a/src/hooks/useTokenList.ts b/src/hooks/useTokenList.ts index 9348058..652eb55 100644 --- a/src/hooks/useTokenList.ts +++ b/src/hooks/useTokenList.ts @@ -1,21 +1,23 @@ -import { useQuery } from '@tanstack/react-query' import type { Address } from 'viem' -import { ADDR } from '../config/addresses' -import { NATIVE, kyberTokenList } from '../lib/kyber' +import { ADDR, NATIVE } from '../config/addresses' import type { TokenInfo } from '../types' import { usePools } from './usePools' +import { useUniPools } from './useUniPools' 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, - }) +type SwapTokenList = { + tokens: TokenInfo[] + up33Loading: boolean + up33Error: Error | null + uniswapSource: 'index' | 'fallback' | null + uniswapError: Error | null +} + +/** Tokens backed by a discovered UP33 or sufficiently liquid Uniswap pool. */ +export function useTokenList(): SwapTokenList { + const up33 = usePools() + const uniswap = useUniPools('', 1_000) const map = new Map() map.set(NATIVE.toLowerCase(), { @@ -24,13 +26,11 @@ export function useTokenList(): TokenInfo[] { decimals: 18, native: true, }) - if (pools.data) { - for (const [k, t] of Object.entries(pools.data.tokens)) map.set(k, t) + if (up33.data) { + for (const [k, t] of Object.entries(up33.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 }) + if (uniswap.data) { + for (const [k, t] of Object.entries(uniswap.data.tokens)) map.set(k, t) } const list = [...map.values()] @@ -40,5 +40,11 @@ export function useTokenList(): TokenInfo[] { if (ai !== -1 || bi !== -1) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) return a.symbol.localeCompare(b.symbol) }) - return list + return { + tokens: list, + up33Loading: up33.isPending, + up33Error: up33.error, + uniswapSource: uniswap.data?.source ?? null, + uniswapError: uniswap.error, + } } diff --git a/src/hooks/useTokenUsd.ts b/src/hooks/useTokenUsd.ts new file mode 100644 index 0000000..fff35ae --- /dev/null +++ b/src/hooks/useTokenUsd.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query' +import { parseUnits } from 'viem' +import { ADDR } from '../config/addresses' +import { kyberUsdValue } from '../lib/kyber' +import { fetchDsTokenUsd } from '../lib/poolstats' +import type { TokenInfo } from '../types' + +/** USD price of 1 whole token (display only). + * Primary: dexscreener's most-liquid robinhood pair — the venue price where + * the volume actually is. Fallback for tokens dexscreener hasn't indexed: a + * fee-free Kyber unit quote, which cherry-picks the best stale mid across + * venues and runs high on everything but ETH (measured +7% on UP). */ +export function useTokenUsd(token: TokenInfo | null) { + const addr = token?.address + return useQuery({ + queryKey: ['tokenUsd', addr?.toLowerCase()], + enabled: !!token, + refetchInterval: 60_000, + staleTime: 50_000, + retry: false, + queryFn: async ({ signal }) => { + try { + // native ETH trades as WETH on every venue dexscreener tracks + return await fetchDsTokenUsd(token!.native ? ADDR.WETH : addr!, signal) + } catch { + const against = addr!.toLowerCase() === ADDR.USDG.toLowerCase() ? ADDR.WETH : ADDR.USDG + return kyberUsdValue(addr!, against, parseUnits('1', token!.decimals), signal) + } + }, + }) +} diff --git a/src/hooks/useUniPoolStats.ts b/src/hooks/useUniPoolStats.ts index 01eae7f..834a069 100644 --- a/src/hooks/useUniPoolStats.ts +++ b/src/hooks/useUniPoolStats.ts @@ -4,7 +4,8 @@ import { useQuery } from '@tanstack/react-query' import type { Address } from 'viem' import { fetchUniIndex } from '../lib/uniIndex' -import type { PoolStat } from '../lib/poolstats' +import { fetchDexscreener, type PoolStat } from '../lib/poolstats' +import { poolStatWithFallback } from '../lib/poolStatFallback' export function useUniPoolStats(addrs: Address[]) { const key = addrs @@ -24,6 +25,16 @@ export function useUniPoolStats(addrs: Address[]) { if (r) Object.assign(out, r.stats) }), ) + const gaps = addrs.filter((a) => { + const stat = out[a.toLowerCase()] + return !stat || stat.vol24hUsd == null || stat.liqUsd == null + }) + const fallback = gaps.length ? await fetchDexscreener(gaps).catch(() => null) : null + for (const address of gaps) { + const key = address.toLowerCase() + const merged = poolStatWithFallback(out[key], fallback?.stats[key]) + if (merged) out[key] = merged + } return out }, }) diff --git a/src/hooks/useUniPools.ts b/src/hooks/useUniPools.ts index eb1ac96..ae78927 100644 --- a/src/hooks/useUniPools.ts +++ b/src/hooks/useUniPools.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { usePublicClient } from 'wagmi' import type { PublicClient } from 'viem' +import { CHAIN_ID } from '../config/addresses' import { fetchUniBrowse } from '../lib/uniBrowse' import { fetchUniIndex } from '../lib/uniIndex' import type { PoolStat } from '../lib/poolstats' @@ -24,11 +25,12 @@ export type UniPoolsData = { * on-chain factory.getPool verification (v3 only, top 30). */ export function useUniPools(query: string, minTvl: number, proto?: 'univ2' | 'univ3') { - const pc = usePublicClient() + const pc = usePublicClient({ chainId: CHAIN_ID }) return useQuery({ queryKey: ['uniPools', query.trim().toLowerCase(), minTvl, proto ?? 'all'], enabled: !!pc, - refetchInterval: 30_000, + // matches the indexer's frontpage sweep — hits our own API, not the RPC + refetchInterval: 15_000, queryFn: async () => { const idx = await fetchUniIndex(query, minTvl, proto) if (idx) return { ...idx, dropped: 0, source: 'index' } diff --git a/src/hooks/useUpPrice.ts b/src/hooks/useUpPrice.ts index daec674..b999590 100644 --- a/src/hooks/useUpPrice.ts +++ b/src/hooks/useUpPrice.ts @@ -1,21 +1,14 @@ -import { useQuery } from '@tanstack/react-query' -import { parseUnits } from 'viem' import { ADDR } from '../config/addresses' -import { kyberRoute } from '../lib/kyber' +import type { TokenInfo } from '../types' +import { useTokenUsd } from './useTokenUsd' -/** USD price of 1 UP via a kyber UP->USDG quote (display only) */ +const UP_TOKEN: TokenInfo = { address: ADDR.UP, symbol: 'UP', decimals: 18 } + +/** USD value of 1 UP (display only) via the shared venue pricer — dexscreener's + * most-liquid pair first, fee-free Kyber unit quote as fallback. Shares the + * ['tokenUsd', UP] cache entry with the swap page, so UP is fetched once + * app-wide. Replaces the direct Kyber unit quote, which ran ~7% high by + * routing through an $8-liquidity stale pool (see lib/tokenPrice.ts). */ 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 - }, - }) + return useTokenUsd(UP_TOKEN) } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b077278..caa7274 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -9,26 +9,39 @@ export const en = { exceedsBalance: 'exceeds balance', bal: 'bal', max: 'MAX', + maxGasTip: 'MAX keeps {{amt}} {{sym}} for origin-chain gas — 25/50/75% use the raw balance', + slipCustom: 'custom', + slipInvalid: 'must be > 0', close: '× CLOSE', noMatch: 'no match', gasToken: '(gas)', tokenSearch: 'symbol or address…', + tokenResolving: 'reading token…', + tokenNotErc20: 'not an ERC-20 on this chain', }, hdr: { pools: 'POOLS', positions: 'POSITIONS', swap: 'SWAP', - epoch: 'epoch', - flip: 'flip', + bridge: 'BRIDGE', blk: 'blk', + history: 'HISTORY', + historyTip: 'activity & transaction log', connect: '[ CONNECT ]', wrongChain: '[ WRONG CHAIN ]', }, + news: { + label: 'NEW', + title: "WHAT'S NEW", + tip: "what's new — recent changes to this terminal", + freshTip: '{{n}} shipped in the last two days', + empty: 'nothing shipped yet', + }, 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', + keys: 'keys: [1] pools [2] positions [3] swap [4] limit [5] bridge', blockscout: 'blockscout↗', reloaded: 'reloaded — a new build was deployed while this tab was open', }, @@ -51,7 +64,8 @@ export const en = { }, lang: { label: 'lang:' }, rbar: { - inRange: 'IN RANGE · {{pct}}% through · band ±{{band}}%', + inRange: 'IN RANGE', + inRangeTip: '{{pct}}% through the band · 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', @@ -60,6 +74,7 @@ export const en = { orderWaitFall: 'ORDER WAITING · price must fall {{pct}}% to start filling', toLow: '→ LOW', fromHigh: 'HIGH ←', + allOf: '100% {{sym}}', flipTip: 'flip price orientation', ticksTip: 'ticks [{{lo}}, {{hi}}] · current {{tick}}', }, @@ -71,18 +86,13 @@ export const en = { approve: 'approve {{sym}}', received: 'received {{amt}} UP', swapToEth: 'SWAP → ETH', - hintKyberMinOut: 'kyber min-out hit — price moved between quote & send; raise SLIP or retry', + hintSwapMinOut: 'minimum output 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', @@ -113,6 +123,14 @@ export const en = { thFeeApr: 'FEE APR', thRewards: 'REWARDS', sortTip: 'click to sort', + addrTip: 'addresses — click to copy', + addrHint: 'click to copy', + addrPool: 'POOL', + addrCopyTip: 'copy full address', + addrCopied: 'COPIED', + addrFailed: 'FAILED', + addrExplorerTip: 'open in explorer', + dsTip: 'open this pair on dexscreener (new tab)', mineDotTip: 'you have a position here', gauge: 'gauge', killed: 'killed', @@ -139,6 +157,8 @@ export const en = { fundZapTip: 'hold only one side? zap swaps the right slice into the other token, then deposits — step by step', range: 'RANGE', full: 'FULL', + more: 'more', + moreTip: 'one-sided ranges, custom %, exact prices or raw ticks', pctCustom: '± % …', pctCustomTip: 'symmetric band, custom percent', above: '↑ ABOVE', @@ -179,15 +199,19 @@ export const en = { stepAddV2: 'add v2 {{pair}} liquidity', stepAdd: 'add {{pair}} liquidity', stepMint: 'mint {{kind}} {{pair}} [{{lo}},{{hi}}]', + stakeAfter: 'stake after', + stakeAfterTip: 'after adding, continue straight into staking the new position to earn UP — remembered until you change it', }, 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', + solving: '> comparing direct Uniswap / UP33 quotes', cantPlan: "> can't plan: {{err}}", planTitle: 'ZAP PLAN', + refreshPlan: 'REFRESH PLAN', + refreshTip: 'the plan auto-refreshes every {{s}}s, then pauses after {{n}} — click to refresh now', split: 'SPLIT', keepAll: 'keep all {{amt}} {{sym}}', keepSwap: 'keep {{keep}} + swap {{swap}} {{sym}}', @@ -196,17 +220,30 @@ export const en = { swapRow: 'SWAP', swapMin: 'min {{amt}} @ {{slip}}%', impact: 'impact {{pct}}%', - impactOff: 'impact n/a (usd marks unreliable — trust min-out)', + impactOff: 'impact unavailable — choose slippage', + chooseSlippage: 'choose slippage before running', + // "terminal fee", not "fee": this panel also shows the pool's fee APR, and + // a bare "fee" reads as that — the label has to say whose cut this is + terminalFee: 'terminal fee {{pct}}%', via: 'via {{route}}', + pickOther: 'other', + swapN: 'SWAP {{n}}', + outsideSplit: 'swap {{a0}} {{sym}} → {{sym0}} + {{a1}} {{sym}} → {{sym1}}', + outsideOne: 'swap all {{amt}} {{sym}} → {{outSym}}', + outsideSd: 'input is outside the pair — both sides are bought at the deposit ratio', 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)', + runSteps: '⚡ RUN ZAP ({{n}} steps)', 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.', + haltedSwap: + 'halted at step {{n}} (swap): the fresh quote fell below your {{slip}}% slippage minimum — the price moved. raise slippage to improve execution odds (AUTO now suggests {{next}}%) and re-run — it re-plans from your CURRENT balances.', + haltedDeposit: + 'halted at step {{n}} (deposit): pool price moved beyond the {{band}}% deposit tolerance mid-flight. just re-run — mins recompute at the live price; swapped tokens are safe in your wallet.', 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.', @@ -215,11 +252,11 @@ export const en = { 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', + errZeroQuote: 'direct quote returned zero — pool/route too thin', + errNoRoute: 'no direct route for the counter-token', + errQuoteFailed: 'direct quote comparison failed for {{protocols}}', 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', @@ -227,11 +264,9 @@ export const en = { stIncrease: 'increase #{{id}}', stMint: 'mint position', stAddLiquidity: 'add liquidity', + stStake: 'stake position → earn UP', 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', @@ -248,7 +283,7 @@ export const en = { lpValueSub: '{{cl}} CL · {{v2}} v2 · {{staked}} staked', lpValueFees: 'fees ≈{{usd}} uncollected', lpValueUnpriced: '{{n}} unpriced', - pendingUp: 'PENDING UP', + pendingRewards: 'PENDING REWARDS', upPerDay: '+{{n}} UP/day', upPerDayUsd: '(≈{{usd}}/day)', claimAll: 'CLAIM ALL ({{n}} tx)', @@ -287,20 +322,19 @@ export const en = { 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}}', + 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 ≈', + earnFeeApr: 'fee APR', earnShareActive: '{{share}} of active liq', earnSharePool: '{{share}} of pool', earnWhileInRange: 'while in range', @@ -309,6 +343,11 @@ export const en = { earnEmpty: 'empty — only uncollected fees remain', earningStaked: 'earning (staked)', earningWallet: 'earning (wallet)', + // same-pool aggregation header (ClPoolGroup) + groupPositions: '{{n}} positions', + groupStakedN: '{{n}} staked', + groupStakedShare: 'staked share', + groupIdle: 'not earning now', orderRow: 'range order {{sell}} → {{buy}}:', orderLockIn: 'WITHDRAW → LOCK IN {{sym}}', orderClose: 'CLOSE NOW (PARTIAL FILL)', @@ -337,9 +376,9 @@ export const en = { v2StakeAll: 'STAKE ALL', v2UnstakeAll: 'UNSTAKE ALL', v2ClaimFees: 'CLAIM FEES', - v2Total: 'total', - v2WalletLp: 'wallet LP', - v2StakedLp: 'staked LP', + v2Lp: 'LP', + v2Staked: '{{n}} staked', + v2Wallet: '{{n}} in wallet', v2Claimable: 'claimable fees', v2RemoveOf: 'of wallet LP', v2RemoveStakedNote: '— staked LP must be unstaked first', @@ -365,42 +404,68 @@ export const en = { market: 'MARKET', limit: 'LIMIT · SELL VIA LP', limitTip: 'sell a token via a one-sided LP (CL range order)', + chainTip: 'network indicator — every quote & swap on this terminal executes on Robinhood Chain (id {{id}})', loadingTokens: '> loading token registry…', - from: 'FROM', - to: 'TO', + sell: 'SELL', + buy: 'BUY', + balTip: 'wallet balance — click to spend max', flipTip: 'flip direction', - rate: 'rate: 1 {{a}} = {{n}} {{b}}', + rateV: '1 {{a}} = {{n}} {{b}}', rateTip: 'click to invert', - quotes: 'QUOTES', - enterAmount: 'enter an amount to quote both routes.', - kyberAgg: 'KYBER AGG', - up33Native: 'UP33 NATIVE', + route: 'ROUTE', + details: 'DETAILS', + kRate: 'RATE', + kMinReceived: 'MIN RECEIVED', + // "IMPACT" is still the BRIDGE row's label, where the number is a signed + // destination-vs-sent delta. The swap side says COST because it is not a + // price impact in the usual sense: that word means the fee-free size move, + // and this number deliberately folds the fees in. + kImpact: 'IMPACT', + kCost: 'COST', + costTip: 'all-in cost of this trade — price move, pool fees, token transfer taxes and terminal fee together, against the price you would get if size and fees were free', + kTerminalFee: 'TERMINAL FEE', + terminalFeeOfWhich: 'our {{pct}}% cut — part of COST above, not on top of it', + kFee: 'FEE', + ctaEnterAmount: 'ENTER AMOUNT', + ctaQuoting: 'QUOTING…', + ctaRefreshQuote: '↻ REFRESH QUOTE', + ctaPending: 'SWAP PENDING', + pendingSaveFailed: 'transaction sent, but pending status could not be saved — keep this tab open', + pendPending: 'PENDING', + pendConfirmed: 'CONFIRMED', + pendFailed: 'FAILED', + pendStale: 'NOT CONFIRMED', + viewTx: 'VIEW', + pendingDismiss: 'clear this row (the transaction is unaffected)', + refreshTip: 'quotes refresh every {{s}}s — click to refresh now', + solverRefreshTip: 'quotes refresh every {{s}}s; Sheep Choice pauses after 3 automatic refreshes — click to refresh now', + usdDeltaTip: 'USD received vs USD sold at venue spot prices — includes price impact & fees', + solverRoute: 'SHEEP CHOICE', + solverDown: 'sheep choice offline — direct venues only', + stSwapSolver: 'swap {{amt}} {{a}} → {{b}} [sheep choice]', + uniswapDirect: 'UNISWAP DIRECT', + up33Direct: 'UP33 DIRECT', + quoteImpact: 'impact {{pct}}%', // bridge cards + quoteCost: 'cost {{pct}}%', + noDirectPool: 'no direct pool for this pair', + quoteFailed: 'direct quote failed', + tokenListFallback: 'Uniswap index unavailable — token list is limited to verified discovery results.', + tokenListFailed: 'Uniswap token list failed: {{err}}', + up33TokenListFailed: 'UP33 pool registry failed: {{err}}', 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)', + chooseSlippage: 'CHOOSE SLIPPAGE', 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)', + stSwapDirect: 'swap {{amt}} {{a}} → {{b}} [{{route}}]', + errQuoteMoved: 'fresh quote is below the displayed minimum — review and retry', + receivedBelowMinimum: 'confirmed output {{got}} is below minimum {{min}} — investigate before retrying', }, limit: { intro: '> maker, not taker: park a one-sided LP beyond market — pay no swap fee, earn fees while it fills', @@ -473,4 +538,56 @@ export const en = { orderLive: 'range order #{{id}} live — fills as {{sym}} appreciates', track: 'TRACK IN POSITIONS', }, + bridge: { + chainTip: 'pick a chain — Robinhood Chain (id {{id}}) always stays on one side of the route', + homeTag: 'HOME', + balFailed: 'balance read failed — click to retry ({{err}})', + from: 'FROM', + to: 'TO', + flipTip: 'flip direction', + route: 'ROUTE', + etaS: '~{{s}}s', + etaMin: '~{{m}} min', + minShort: 'min {{amt}} {{sym}}', + quoteFailed: 'quote failed', + kEta: 'EST TIME', + kProviderCost: 'BRIDGE COST', + impactTip: 'destination amount vs sent amount — bridge cost + terminal fee, all-in. click to expand', + terminalFeeTip: 'terminal fee {{pct}}% — charged inside the provider quote, never a separate transfer', + impactWarn: + 'total cost {{pct}}% is high — thin route liquidity right now. compare providers, try a smaller size, or bridge via USDG instead of ETH', + noRoute: 'NO ROUTE', + execVia: 'BRIDGE VIA {{provider}} →', + stDeposit: 'bridge {{amt}} {{sym}}: {{from}} → {{to}}', + stgApprove: 'APPROVE', + stgDeposit: 'SEND', + stgSent: 'SENT', + sentNote: 'deposit confirmed — arrival tracked under PENDING', + filled: 'bridge filled — funds delivered on the destination chain', + refunded: 'bridge did not fill — refunded on the origin chain (minus gas)', + fillFailed: 'bridge fill FAILED — recheck with your deposit tx on the provider explorer', + quoteExpired: 'quote expired — refresh quotes and retry', + switchFailed: 'network switch declined: {{err}}', + tokenTip: 'pick a token — only same-token routes the connected bridges actually support are listed', + nRoutes: '{{n}} ROUTES', + nRoute1: '1 ROUTE', + discovering: 'discovering routes…', + noTokens: 'no bridgeable tokens for this pair right now', + discoverFailed: 'route discovery failed', + retry: '↻ RETRY', + canonical: 'CANONICAL', + canonicalTip: + 'official Arbitrum bridge — lossless 1:1, you only pay L1 gas; measured delivery ~10 min. best price ranks first regardless of speed', + pendingTitle: 'PENDING', + pendingTracked: 'bridge deposit sent — arrival {{eta}}, tracked under BRIDGE › PENDING', + etaLeft: '{{eta}} left', + verifying: 'VERIFYING', + pFilled: 'FILLED', + pRefunded: 'REFUNDED', + pFailed: 'FAILED', + pStale: 'STILL PENDING', + stale: 'pending for over an hour — recheck, or verify with the deposit tx on the explorer', + recheckTip: 'check status now', + dismissTip: 'remove from list (the transfer itself is unaffected)', + }, } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index d4487c0..26fb5d3 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -10,26 +10,39 @@ export const zh: typeof en = { exceedsBalance: '超出余额', bal: '余额', max: 'MAX', + maxGasTip: 'MAX 会保留 {{amt}} {{sym}} 作源链 gas — 25/50/75% 按原始余额计算', + slipCustom: '自定义', + slipInvalid: '需大于 0', close: '× 收起', noMatch: '无匹配', gasToken: '(gas)', tokenSearch: '符号或地址…', + tokenResolving: '读取代币中…', + tokenNotErc20: '不是本链的 ERC-20', }, hdr: { pools: '池子', positions: '仓位', swap: '兑换', - epoch: 'epoch', - flip: '翻转', + bridge: '跨链', blk: '块', + history: '历史', + historyTip: '活动与交易日志', connect: '[ 连接 ]', wrongChain: '[ 链错误 ]', }, + news: { + label: '更新', + title: '最近更新', + tip: '更新内容 — 本终端的最近改动', + freshTip: '最近两天有 {{n}} 条更新', + empty: '暂无更新', + }, app: { wrongNetwork: '!! 网络错误 — 本终端只在 Robinhood Chain (4663) 上写入', switch: '切换', tagline: 'LP TERMINAL v0.2 · 精确授权 · 链上实时读取', - keys: '快捷键: [1] 池子 [2] 仓位 [3] 兑换 [4] 限价', + keys: '快捷键: [1] 池子 [2] 仓位 [3] 兑换 [4] 限价 [5] 跨链', blockscout: 'blockscout↗', reloaded: '已刷新 — 此标签页打开期间部署了新版本', }, @@ -52,7 +65,8 @@ export const zh: typeof en = { }, lang: { label: 'lang:' }, rbar: { - inRange: '区间内 · 已穿过 {{pct}}% · 带宽 ±{{band}}%', + inRange: '区间内', + inRangeTip: '已穿过 {{pct}}% · 带宽 ±{{band}}%', outRise: '超出区间 · 价格需上涨 {{pct}}% 才能回到区间', outFall: '超出区间 · 价格需下跌 {{pct}}% 才能回到区间', orderFilled: '订单已完全成交 · 100% 持有 {{sym}} — 提取以锁定', @@ -61,6 +75,7 @@ export const zh: typeof en = { orderWaitFall: '订单等待中 · 价格需下跌 {{pct}}% 开始成交', toLow: '→ 下界', fromHigh: '上界 ←', + allOf: '100% {{sym}}', flipTip: '翻转价格方向', ticksTip: 'tick [{{lo}}, {{hi}}] · 当前 {{tick}}', }, @@ -72,18 +87,13 @@ export const zh: typeof en = { approve: '授权 {{sym}}', received: '到账 {{amt}} UP', swapToEth: '兑换 → ETH', - hintKyberMinOut: '触发 kyber 最小到账保护 — 报价到发送之间价格变了;调高滑点或重试', + hintSwapMinOut: '触发最小到账保护 — 报价到发送之间价格变了;调高滑点或重试', 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: '全部', @@ -114,6 +124,14 @@ export const zh: typeof en = { thFeeApr: '费率 APR', thRewards: '排放奖励', sortTip: '点击排序', + addrTip: '地址 — 点击复制', + addrHint: '点击复制', + addrPool: '池子', + addrCopyTip: '复制完整地址', + addrCopied: '已复制', + addrFailed: '复制失败', + addrExplorerTip: '在浏览器中打开', + dsTip: '在 dexscreener 打开该交易对(新标签页)', mineDotTip: '你在此池有仓位', gauge: 'gauge', killed: '已停用', @@ -140,6 +158,8 @@ export const zh: typeof en = { fundZapTip: '只持有一边?zap 先把合适的份额换成另一种代币,再存入 — 逐步执行', range: '区间', full: '全区间', + more: '更多', + moreTip: '单边区间、自定义 %、精确价格或原始 tick', pctCustom: '± % …', pctCustomTip: '对称区间,自定义百分比', above: '↑ 上方', @@ -180,15 +200,19 @@ export const zh: typeof en = { stepAddV2: '添加 v2 {{pair}} 流动性', stepAdd: '添加 {{pair}} 流动性', stepMint: '铸造 {{kind}} {{pair}} [{{lo}},{{hi}}]', + stakeAfter: '添加后质押', + stakeAfterTip: '添加后直接继续质押新仓位以赚取 UP —— 该选择会被记住,直到你更改', }, zap: { zapIn: 'ZAP 输入', ethTip: '原生 ETH — 第 1 步先包装成 WETH', slip: '滑点', slipHint: '兑换腿最小到账 · 存入最小值按 1% 带边计算', - solving: '> 正在通过 kyber 求解拆分', + solving: '> 正在比较 Uniswap / UP33 直连报价', cantPlan: '> 无法规划: {{err}}', planTitle: 'ZAP 计划', + refreshPlan: '刷新计划', + refreshTip: '计划每 {{s}} 秒自动刷新,{{n}} 次后暂停 — 点击立即刷新', split: '拆分', keepAll: '全部保留 {{amt}} {{sym}}', keepSwap: '保留 {{keep}} + 兑换 {{swap}} {{sym}}', @@ -197,16 +221,29 @@ export const zh: typeof en = { swapRow: '兑换', swapMin: '最少 {{amt}} @ {{slip}}%', impact: '价格影响 {{pct}}%', - impactOff: '价格影响不可用(美元标价不可靠 — 以最小到账为准)', + impactOff: '价格影响不可用 — 请选择滑点', + chooseSlippage: '执行前请选择滑点', + // 不用「手续费」:本 catalog 里它 15 处全指池子手续费,同面板下方就是 + // 「费率 APR」——裸写「手续费」会被读成池子费,等于没披露 + terminalFee: '终端费 {{pct}}%', via: '经 {{route}}', + pickOther: '其他', + swapN: '兑换 {{n}}', + outsideSplit: '兑换 {{a0}} {{sym}} → {{sym0}} + {{a1}} {{sym}} → {{sym1}}', + outsideOne: '全部 {{amt}} {{sym}} 兑换为 {{outSym}}', + outsideSd: '输入币不在交易对内 — 两侧均按存入比例买入', depositRow: '存入', dustSd: '预计零头 {{dust}} 留在钱包(不会发送)', actualSd: '兑换侧按实际到账存入,不按报价', bandWarn: '兑换影响 {{impact}}% 相对 ±{{band}}% 区间过大 — 会产生零头;建议放宽区间或减少数量', run: '⚡ 执行 ZAP', - runTx: '⚡ 执行 ZAP({{n}} 笔交易)', + runSteps: '⚡ 执行 ZAP({{n}} 步)', runHint: '任一步失败即停 — 资金都在你钱包里,不会悬空', halted: '在第 {{n}} 步停止 — 已完成的步骤都是普通钱包余额。解决后重新执行(会按当前余额重新规划),或用双币模式收尾。', + haltedSwap: + '在第 {{n}} 步(兑换)停止:最新报价已低于 {{slip}}% 滑点下限 — 价格动了。请提高滑点以增加执行成功率(AUTO 已上调为 {{next}}%),然后重新执行 — 会按当前余额重新规划。', + haltedDeposit: + '在第 {{n}} 步(存入)停止:交易在途时池价越过了 {{band}}% 存入容差。直接重试即可 — 最小值按实时价格重算;已兑换的代币安全在钱包。', done: '✓ zap 完成 — 流动性已生效。', replanFailed: 'zap 重新规划失败: {{err}}', orderGrows: '这会加大未平的区间订单 — 同区间、同均价、更大规模。', @@ -214,11 +251,11 @@ export const zh: typeof en = { errNotInPool: '该代币不在此池中', errNoReserves: '池子无储备 — 用双币模式初始注入', errNoPrice: '池价格不可用', - errZeroQuote: 'kyber 报价为零 — 池/路由太薄', - errNoRoute: '对手代币没有 kyber 路由', + errZeroQuote: '直连报价为零 — 池/路由太薄', + errNoRoute: '对手代币没有直连路由', + errQuoteFailed: '{{protocols}} 直连报价比较失败', errTooSmall: '数量对该区间来说太小', stWrap: '包装 {{amt}} ETH → WETH', - stApproveKyber: '授权 {{sym}} → kyber 路由', stApproveSpender: '授权 {{sym}} → {{spender}}', spenderRouter: '路由', spenderNpm: '仓位管理器', @@ -226,11 +263,9 @@ export const zh: typeof en = { stIncrease: '加仓 #{{id}}', stMint: '铸造仓位', stAddLiquidity: '添加流动性', + stStake: '质押仓位 → 赚取 UP', halt: 'zap 已停止 — {{msg}}', - haltRequote: '重新报价失败: {{err}}', - haltPriceMoved: '价格自预览后变动(现 {{now}},预览 {{prev}})— 请核对后重新执行', haltTokenOut: '路由 tokenOut {{addr}} 不是池子对手代币 — 已中止', - haltNoTransfer: '兑换成功但未找到对手代币转账 — 请先排查再重试', haltNothing: '没有可存入的数量', haltDepositSmall: '当前价格下存入量对该区间太小(已换的代币在你钱包里)', stakeHint: 'zap 完成 — 质押仓位开始赚 UP', @@ -247,7 +282,7 @@ export const zh: typeof en = { lpValueSub: '{{cl}} CL · {{v2}} v2 · {{staked}} 已质押', lpValueFees: '未领手续费 ≈{{usd}}', lpValueUnpriced: '{{n}} 个未定价', - pendingUp: '待领 UP', + pendingRewards: '待领奖励', upPerDay: '+{{n}} UP/天', upPerDayUsd: '(≈{{usd}}/天)', claimAll: '全部领取({{n}} 笔)', @@ -286,20 +321,19 @@ export const zh: typeof en = { withdrawTip: '移除 100% 流动性并领取全部(双击确认)', value: '价值', noAnchor: '— 无价格锚', - holds: '持有', pendingUpK: '待领 UP', fees: '未领手续费', levyNote: '(10% 未质押抽成已扣除)', earning: '收益中', - earnEmit: '≈ {{n}} UP/天', - earnUsdDay: '({{usd}}/天)', - earnApr: 'APR ≈ {{apr}}', + earnEmit: '{{n}} UP/天', + earnUsdDay: '≈ {{usd}}/天', + earnApr: 'APR {{apr}}', earnShareStaked: '占质押流动性 {{share}}', earnShareGauge: '占 gauge {{share}}', earnOutStaked: '0 — 超出区间', earnOutStakedTip: '质押 CL 只在价格处于区间内时发 UP', earnEnded: '0 — 本 epoch 无 UP 排放流入', - earnFeeApr: '费率 APR ≈', + earnFeeApr: '费率 APR', earnShareActive: '占活跃流动性 {{share}}', earnSharePool: '占池子 {{share}}', earnWhileInRange: '区间内有效', @@ -308,6 +342,11 @@ export const zh: typeof en = { earnEmpty: '空仓 — 仅剩未领手续费', earningStaked: '收益中(质押)', earningWallet: '收益中(钱包)', + // same-pool aggregation header (ClPoolGroup) + groupPositions: '{{n}} 个仓位', + groupStakedN: '{{n}} 个质押', + groupStakedShare: '质押占比', + groupIdle: '当前无收益', orderRow: '区间订单 {{sell}} → {{buy}}:', orderLockIn: '提取 → 锁定 {{sym}}', orderClose: '立即平仓(部分成交)', @@ -336,9 +375,9 @@ export const zh: typeof en = { v2StakeAll: '全部质押', v2UnstakeAll: '全部解押', v2ClaimFees: '领取手续费', - v2Total: '合计', - v2WalletLp: '钱包 LP', - v2StakedLp: '质押 LP', + v2Lp: 'LP', + v2Staked: '{{n}} 已质押', + v2Wallet: '{{n}} 在钱包', v2Claimable: '可领手续费', v2RemoveOf: '按钱包 LP 计', v2RemoveStakedNote: '— 质押的 LP 需先解押', @@ -363,42 +402,67 @@ export const zh: typeof en = { market: '市价', limit: '限价 · 挂 LP 卖出', limitTip: '用单边 LP(CL 区间订单)卖出代币', + chainTip: '网络标识 — 本终端的所有报价与交易均在 Robinhood Chain(id {{id}})上执行', loadingTokens: '> 加载代币列表…', - from: '卖出', - to: '买入', + sell: '卖出', + buy: '买入', + balTip: '钱包余额 — 点击填入最大可用', flipTip: '互换方向', - rate: '汇率: 1 {{a}} = {{n}} {{b}}', + rateV: '1 {{a}} = {{n}} {{b}}', rateTip: '点击反转', - quotes: '报价', - enterAmount: '输入数量以获取两路报价。', - kyberAgg: 'KYBER 聚合', - up33Native: 'UP33 原生', + route: '路由', + details: '明细', + kRate: '汇率', + kMinReceived: '最少到账', + // 「价格影响」仍是桥接那一行的标签,那里是到账/发送的带符号差值。兑换这边叫 + // 「成交成本」:price impact 惯例上指不含费的规模冲击,而这个数是刻意把费用 + // 折进去的,沿用旧词只会继续误导。 + kImpact: '价格影响', + kCost: '成交成本', + costTip: '本笔交易的全部成本 — 价格推动、池子手续费、代币转账税与终端手续费合计,相对于「不受规模与费用影响时能拿到的价格」', + kTerminalFee: '终端手续费', + terminalFeeOfWhich: '我们收取的 {{pct}}% — 已包含在上面的成交成本里,不是额外加收', + kFee: '费用', + ctaEnterAmount: '输入数量', + ctaQuoting: '询价中…', + ctaRefreshQuote: '↻ 刷新报价', + ctaPending: '交易确认中', + pendingSaveFailed: '交易已发送,但待确认状态无法保存 — 请保持此页面开启', + pendPending: '待确认', + pendConfirmed: '已确认', + pendFailed: '失败', + pendStale: '未确认', + viewTx: '查看', + pendingDismiss: '清除此行(不影响链上交易)', + refreshTip: '报价每 {{s}} 秒自动刷新 — 点击立即刷新', + solverRefreshTip: '报价每 {{s}} 秒刷新;SHEEP CHOICE 自动刷新 3 次后暂停 — 点击立即刷新', + usdDeltaTip: '买入侧与卖出侧的美元价值差(按场馆现价)— 已包含价格影响与费用', + solverRoute: 'SHEEP CHOICE', + solverDown: 'SHEEP CHOICE 离线 — 仅直连场所', + stSwapSolver: '兑换 {{amt}} {{a}} → {{b}} [sheep choice]', + uniswapDirect: 'UNISWAP 直连', + up33Direct: 'UP33 直连', + quoteImpact: '影响 {{pct}}%', // 桥接卡片 + quoteCost: '成本 {{pct}}%', + noDirectPool: '该交易对没有直连池', + quoteFailed: '直连报价失败', + tokenListFallback: 'Uniswap 索引不可用 — 代币列表仅显示已验证的发现结果。', + tokenListFailed: 'Uniswap 代币列表失败: {{err}}', + up33TokenListFailed: 'UP33 池注册表失败: {{err}}', 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)', + chooseSlippage: '请选择滑点', 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)', + stSwapDirect: '兑换 {{amt}} {{a}} → {{b}} [{{route}}]', + errQuoteMoved: '最新报价低于页面显示的最小到账 — 请核对后重试', + receivedBelowMinimum: '确认到账 {{got}} 低于最小值 {{min}} — 请先排查再重试', }, limit: { intro: '> 做挂单方,不做吃单方:在市场价之外挂单边 LP — 不付交易费,成交过程还赚手续费', @@ -470,4 +534,54 @@ export const zh: typeof en = { orderLive: '区间订单 #{{id}} 已挂出 — {{sym}} 升值时逐步成交', track: '在仓位页跟踪', }, + bridge: { + chainTip: '选择链 — Robinhood Chain (id {{id}}) 永远是路径的一端', + homeTag: '主链', + balFailed: '余额读取失败 — 点击重试({{err}})', + from: '从', + to: '到', + flipTip: '反转方向', + route: '路由', + etaS: '约{{s}}秒', + etaMin: '约{{m}}分钟', + minShort: '最少 {{amt}} {{sym}}', + quoteFailed: '报价失败', + kEta: '预计时间', + kProviderCost: '桥成本', + impactTip: '到账 vs 发送的全部损耗 — 桥成本 + 终端费。点击展开', + terminalFeeTip: '终端费 {{pct}}% — 在报价内收取,不产生额外转账', + impactWarn: '总损耗 {{pct}}% 偏高 — 当前路由流动性薄。可对比各家报价、减小金额,或改用 USDG 出入金', + noRoute: '无可用路由', + execVia: '通过 {{provider}} 跨链 →', + stDeposit: '跨链 {{amt}} {{sym}}: {{from}} → {{to}}', + stgApprove: '授权', + stgDeposit: '发送', + stgSent: '已发送', + sentNote: '入金已确认 — 到账进度见上方 PENDING', + filled: '跨链完成 — 资产已到目标链', + refunded: '未成交 — 已在源链退款(扣除 gas)', + fillFailed: '目标链成交失败 — 请用入金交易哈希到桥方浏览器核查', + quoteExpired: '报价已过期 — 刷新报价后重试', + switchFailed: '切换网络被拒绝: {{err}}', + tokenTip: '选择代币 — 仅列出各桥实际支持的同代币路由', + nRoutes: '{{n}} 条路由', + nRoute1: '1 条路由', + discovering: '正在发现路由…', + noTokens: '该链对当前无可桥代币', + discoverFailed: '路由发现失败', + retry: '↻ 重试', + canonical: 'CANONICAL', + canonicalTip: '官方 Arbitrum 桥 — 1:1 无损,仅付 L1 gas;实测到账约 10 分钟。价格最优即排第一,不论速度', + pendingTitle: 'PENDING', + pendingTracked: '入金已发送 — 预计 {{eta}} 到账,进度见 BRIDGE › PENDING', + etaLeft: '剩 {{eta}}', + verifying: '核对中', + pFilled: '已到账', + pRefunded: '已退款', + pFailed: '失败', + pStale: '仍在等待', + stale: '等待超过 1 小时 — 可手动重查,或用入金交易哈希到浏览器核实', + recheckTip: '立即查询状态', + dismissTip: '从列表移除(不影响转账本身)', + }, } diff --git a/src/lib/apr.ts b/src/lib/apr.ts index 04bfa69..df60005 100644 --- a/src/lib/apr.ts +++ b/src/lib/apr.ts @@ -1,6 +1,6 @@ // APR math — pool-level columns AND per-position add-LP simulation. // -// ve(3,3) ground rules: +// ve(3,3) ground rules (see docs/up33-contract-map.md): // 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. diff --git a/src/lib/autostake.ts b/src/lib/autostake.ts new file mode 100644 index 0000000..13c1d43 --- /dev/null +++ b/src/lib/autostake.ts @@ -0,0 +1,19 @@ +// Remembered preference: after a Zap/Pair into a stakeable pool, continue into +// the stake flow by default. A tiny external store (localStorage-backed) so the +// checkbox reflects toggles made from any add surface — POOLS, POSITIONS, ZAP. +const KEY = 'lp.stakeAfter' +let on = localStorage.getItem(KEY) !== '0' // default on +const subs = new Set<() => void>() + +export const autostake = { + get: (): boolean => on, + set(value: boolean) { + on = value + localStorage.setItem(KEY, value ? '1' : '0') + subs.forEach((f) => f()) + }, + subscribe(f: () => void): () => void { + subs.add(f) + return () => subs.delete(f) + }, +} diff --git a/src/lib/bridge/across.ts b/src/lib/bridge/across.ts new file mode 100644 index 0000000..acd205c --- /dev/null +++ b/src/lib/bridge/across.ts @@ -0,0 +1,122 @@ +// Across quote + status via the Swap API (works for plain bridges AND +// composed any-to-any routes — e.g. ETH out of Robinhood becomes +// ETH→USDG→bridge→ETH in ONE origin tx, ~5x cheaper than a direct ETH exit). +// Keyless, CORS-open. Fee note: appFee is a DECIMAL FRACTION (0.001 = 0.1%) +// and settles instantly in the destination-side output token — see +// docs/bridge-research.md. +import { encodeFunctionData, erc20Abi, type Address } from 'viem' +import { NATIVE_SENTINEL, type ResolvedIntent } from '../../config/bridge' +import { BridgeQuoteError, type BridgeFee, type BridgeQuote, type BridgeStep } from './types' +import { QUOTE_PLACEHOLDER } from './relay' + +const ACROSS_API = 'https://app.across.to/api' + +/** Across's appFee unit is a decimal fraction: 10 bps -> "0.001" */ +export const acrossAppFee = (bps: number): string => (bps / 10_000).toString() + +export type AcrossQuoteJson = { + checks?: { + allowance?: { token?: Address; spender?: Address; actual?: string; expected?: string } + } + swapTx?: { chainId: number; to: Address; data: `0x${string}`; value?: string | null } + expectedOutputAmount?: string + minOutputAmount?: string + expectedFillTime?: number + quoteExpiryTimestamp?: number + message?: string + code?: string +} + +/** pure mapper. Across's own approvalTxns use infinite allowances — this + * terminal's invariant is exact approvals, so the approve step is rebuilt + * from checks.allowance for exactly the required amount. */ +export function mapAcrossQuote(json: AcrossQuoteJson): BridgeQuote { + const tx = json.swapTx + if (!tx || !json.expectedOutputAmount) { + throw new BridgeQuoteError(json.message ?? 'across quote response is missing swapTx', json.code ?? null) + } + const steps: BridgeStep[] = [] + const allowance = json.checks?.allowance + if ( + allowance?.token && + allowance.spender && + allowance.token.toLowerCase() !== NATIVE_SENTINEL.toLowerCase() && + BigInt(allowance.actual ?? '0') < BigInt(allowance.expected ?? '0') + ) { + steps.push({ + kind: 'approve', + chainId: tx.chainId, + to: allowance.token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [allowance.spender, BigInt(allowance.expected ?? '0')], + }), + value: 0n, + }) + } + steps.push({ + kind: 'deposit', + chainId: tx.chainId, + to: tx.to, + data: tx.data, + value: BigInt(tx.value ?? '0'), + }) + return { + provider: 'across', + outputAmount: BigInt(json.expectedOutputAmount), + minOutput: BigInt(json.minOutputAmount ?? json.expectedOutputAmount), + etaSec: json.expectedFillTime ?? 0, + steps, + tracker: { provider: 'across', originChainId: tx.chainId }, + expiresAt: json.quoteExpiryTimestamp ?? null, + } +} + +export async function quoteAcross( + leg: ResolvedIntent, + amount: bigint, + fee: BridgeFee, + user: Address | null, + signal?: AbortSignal, +): Promise { + const payer = user ?? QUOTE_PLACEHOLDER + const params = new URLSearchParams({ + amount: amount.toString(), + inputToken: leg.inputToken, + outputToken: leg.outputToken, + originChainId: String(leg.originChainId), + destinationChainId: String(leg.destChainId), + depositor: payer, + refundAddress: payer, + }) + // zero-fee mode omits appFee — don't bet on providers accepting "0" + if (fee.bps > 0) { + params.set('appFee', acrossAppFee(fee.bps)) + params.set('appFeeRecipient', fee.receiver) + } + const res = await fetch(`${ACROSS_API}/swap/approval?${params}`, { signal: signal ?? null }) + const json = (await res.json()) as AcrossQuoteJson + if (!res.ok) { + throw new BridgeQuoteError(json.message ?? `across quote failed (${res.status})`, json.code ?? null) + } + return mapAcrossQuote(json) +} + +// ---- fill tracking ---- + +export type AcrossStatus = 'pending' | 'filled' | 'expired' | 'refunded' + +export async function fetchAcrossStatus( + originChainId: number, + depositTxHash: string, +): Promise<{ status: AcrossStatus; fillTx?: string; destinationChainId?: number }> { + const res = await fetch( + `${ACROSS_API}/deposit/status?originChainId=${originChainId}&depositTxnRef=${depositTxHash}`, + ) + // the indexer lags the deposit tx by a few seconds — treat not-found as pending + if (!res.ok) return { status: 'pending' } + const json = (await res.json()) as { status?: string; fillTx?: string; destinationChainId?: number } + const status = (json.status === 'unfilled' ? 'pending' : json.status) as AcrossStatus | undefined + return { status: status ?? 'pending', fillTx: json.fillTx, destinationChainId: json.destinationChainId } +} diff --git a/src/lib/bridge/bridge.test.ts b/src/lib/bridge/bridge.test.ts new file mode 100644 index 0000000..04a3a09 --- /dev/null +++ b/src/lib/bridge/bridge.test.ts @@ -0,0 +1,518 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { encodeAbiParameters, encodeFunctionData, erc20Abi, pad, toFunctionSelector, toHex, type Address, type Hex, type TransactionReceipt } from 'viem' +import { NATIVE_SENTINEL, PORTAL_ETA_SEC, PORTAL_INBOX, REMOTE_CHAINS, resolveIntent, type BridgeTokenOption } from '../../config/bridge' +import { acrossAppFee, mapAcrossQuote, quoteAcross, type AcrossQuoteJson } from './across' +import { checkPendingTransfer, fmtEtaShort, nextCheckAt, pendingBridges, type PendingTransfer } from './pending' +import { aliasL1Address, childEthDepositTxHash, DEPOSIT_ETH_CALLDATA, parseEthDepositReceipt, quotePortal } from './portal' +import { mapRelayQuote, quoteRelay, relayAppFee, type RelayQuoteJson } from './relay' +import { mergeTokenSupports, providersFor, sameSymbolLoose, type AcrossRouteJson, type RelayChainsJson, type RelayCurrencyV2 } from './tokens' +import { BridgeQuoteError, type BridgeFee } from './types' + +const ETH_REMOTE = REMOTE_CHAINS[0] // ETHEREUM +const BASE_REMOTE = REMOTE_CHAINS.find((r) => r.label === 'BASE')! + +const RH_USDG = '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168' as Address +const L1_USDG = '0xe343167631d89B6Ffc58B88d6b7fB0228795491D' as Address +const RH_WETH = '0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73' as Address +const L1_WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as Address +const L1_USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Address + +const USDG_OPTION: BridgeTokenOption = { + symbol: 'USDG', + decimals: 6, + robinhoodToken: RH_USDG, + remoteToken: L1_USDG, + providers: ['relay', 'across'], +} +const ETH_OPTION: BridgeTokenOption = { + symbol: 'ETH', + decimals: 18, + robinhoodToken: NATIVE_SENTINEL, + remoteToken: NATIVE_SENTINEL, + providers: ['portal', 'relay', 'across'], +} + +// fixtures are trimmed live API captures (2026-07-18), shapes verbatim +const RELAY_USDG_OUT: RelayQuoteJson = { + steps: [ + { + id: 'approve', + kind: 'transaction', + requestId: '0xcfb2f6aa3bbe7a8fedc5a7ab20a87c0d97f39136250a935da21d925e8aeabb34', + items: [ + { + data: { + to: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168', + data: '0x095ea7b3', + value: '0', + chainId: 4663, + }, + check: null, + }, + ], + }, + { + id: 'deposit', + kind: 'transaction', + requestId: '0xcfb2f6aa3bbe7a8fedc5a7ab20a87c0d97f39136250a935da21d925e8aeabb34', + items: [ + { + data: { + to: '0x4cd00e387622c35bddb9b4c962c136462338bc31', + data: '0xe8017952', + value: null, + chainId: 4663, + }, + check: { endpoint: '/intents/status?requestId=0xcfb2…' }, + }, + ], + }, + ], + details: { + currencyOut: { amount: '99831382', minimumAmount: '97834754' }, + timeEstimate: 1, + }, +} + +const ACROSS_USDG_OUT: AcrossQuoteJson = { + checks: { + allowance: { + token: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168', + spender: '0xD29C85F15DF544bA632C9E25829fd29d767d7978', + actual: '0', + expected: '100000000', + }, + }, + swapTx: { + chainId: 4663, + to: '0xD29C85F15DF544bA632C9E25829fd29d767d7978', + data: '0xad5425c6', + value: null, + }, + expectedOutputAmount: '99830520', + minOutputAmount: '99830520', + expectedFillTime: 3, + quoteExpiryTimestamp: 1784358599, +} + +const ACROSS_ETH_IN: AcrossQuoteJson = { + checks: { + allowance: { + token: '0x0000000000000000000000000000000000000000', + spender: '0x10D8b8DaA26d307489803e10477De69C0492B610', + actual: '115792089237316195423570985008687907853269984665640564039457584007913129639935', + expected: '10000000000000000', + }, + }, + swapTx: { + chainId: 8453, + to: '0x10D8b8DaA26d307489803e10477De69C0492B610', + data: '0x1a2b3c4d', + value: '10000000000000000', + }, + expectedOutputAmount: '9963410317377467', + minOutputAmount: '9963410317377467', + expectedFillTime: 1, +} + +test('relay mapper: approve+deposit steps, requestId, bigint outputs', () => { + const q = mapRelayQuote(RELAY_USDG_OUT) + assert.equal(q.provider, 'relay') + assert.deepEqual( + q.steps.map((s) => s.kind), + ['approve', 'deposit'], + ) + assert.equal(q.steps[0].chainId, 4663) + assert.equal(q.steps[1].value, 0n) // null value -> 0n + assert.equal(q.outputAmount, 99831382n) + assert.equal(q.minOutput, 97834754n) + assert.equal(q.etaSec, 1) + assert.deepEqual(q.tracker, { + provider: 'relay', + requestId: '0xcfb2f6aa3bbe7a8fedc5a7ab20a87c0d97f39136250a935da21d925e8aeabb34', + }) + assert.equal(q.expiresAt, null) +}) + +test('relay mapper refuses non-transaction step kinds', () => { + const bad: RelayQuoteJson = { + ...RELAY_USDG_OUT, + steps: [{ id: 'authorize', kind: 'signature', items: [] }], + } + assert.throws(() => mapRelayQuote(bad), BridgeQuoteError) +}) + +test('across mapper rebuilds an EXACT approve (never the API infinite one)', () => { + const q = mapAcrossQuote(ACROSS_USDG_OUT) + assert.deepEqual( + q.steps.map((s) => s.kind), + ['approve', 'deposit'], + ) + const approve = q.steps[0] + assert.equal(approve.to, '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168') + assert.equal( + approve.data, + encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: ['0xD29C85F15DF544bA632C9E25829fd29d767d7978', 100000000n], + }), + ) + assert.equal(q.steps[1].value, 0n) // swapTx.value null -> 0n + assert.equal(q.outputAmount, 99830520n) + assert.equal(q.expiresAt, 1784358599) + assert.deepEqual(q.tracker, { provider: 'across', originChainId: 4663 }) +}) + +test('across mapper: native input needs no approve, keeps value', () => { + const q = mapAcrossQuote(ACROSS_ETH_IN) + assert.deepEqual( + q.steps.map((s) => s.kind), + ['deposit'], + ) + assert.equal(q.steps[0].value, 10000000000000000n) + assert.equal(q.tracker.provider === 'across' && q.tracker.originChainId, 8453) +}) + +test('across mapper surfaces provider errors', () => { + assert.throws(() => mapAcrossQuote({ message: 'Unable to find tokenDetails', code: 'ROUTE_NOT_ENABLED' }), BridgeQuoteError) +}) + +test('fee units per provider derive from one bps number', () => { + // Relay: bps as string; Across: decimal fraction — mixing these up would + // charge 100x, so pin both encodings (9 bps as the example rate) + assert.equal(relayAppFee(9), '9') + assert.equal(acrossAppFee(9), '0.0009') +}) + +test('zero-fee quotes omit provider fee params entirely', async () => { + // fee.bps 0 must drop appFees/appFee from the requests — sending "0" would + // gamble on both providers' validation instead of our own + const leg = resolveIntent({ dir: 'out', token: USDG_OPTION, remote: ETH_REMOTE, amount: 1_000_000n }) + const fee: BridgeFee = { bps: 0, receiver: '0x1111111111111111111111111111111111111111' as Address } + const seen: { relayBody?: Record; acrossUrl?: string } = {} + const orig = globalThis.fetch + globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + const u = String(url) + if (init?.body) seen.relayBody = JSON.parse(String(init.body)) as Record + else seen.acrossUrl = u + return new Response(JSON.stringify({ message: 'stub reject' }), { status: 500 }) + }) as typeof fetch + try { + await assert.rejects(quoteRelay(leg, 1_000_000n, fee, null), BridgeQuoteError) + await assert.rejects(quoteAcross(leg, 1_000_000n, fee, null), BridgeQuoteError) + } finally { + globalThis.fetch = orig + } + assert.ok(seen.relayBody && !('appFees' in seen.relayBody)) + assert.ok(seen.acrossUrl && !seen.acrossUrl.includes('appFee')) +}) + +test('intent resolution: robinhood is always one leg, same token both sides', () => { + const out = resolveIntent({ dir: 'out', token: ETH_OPTION, remote: ETH_REMOTE, amount: 1n }) + assert.equal(out.originChainId, 4663) + assert.equal(out.destChainId, ETH_REMOTE.chain.id) + const usdgIn = resolveIntent({ dir: 'in', token: USDG_OPTION, remote: ETH_REMOTE, amount: 1n }) + assert.equal(usdgIn.originChainId, ETH_REMOTE.chain.id) + assert.equal(usdgIn.inputToken, L1_USDG) + assert.equal(usdgIn.outputToken, RH_USDG) + assert.equal(usdgIn.inputDecimals, 6) + assert.equal(usdgIn.inputSymbol, 'USDG') + assert.equal(usdgIn.outputSymbol, 'USDG') +}) + +// ---- canonical bridge (portal) ---- + +// real deposit pair (L1 tx 0x4bbe5afd…, validated live 2026-07-18): the bridge +// event's aliased sender, the InboxMessageDelivered payload and the resulting +// child tx hash on Robinhood +const FIXTURE = { + msgNum: 43494n, + aliasedSender: '0xc66B13d57560540773956d0A78D2f18f0e30F8FF' as Address, + dest: '0xb55a13d57560540773956D0a78d2F18f0e30E7EE' as Address, + value: 90658430000000000n, + childTxHash: '0x5ed39f3bf5979435ead99b4b45aa62be82434caf60872dacc0d4b4d21764eb2e' as Hex, +} + +test('portal: depositEth calldata is the real selector', () => { + assert.equal(DEPOSIT_ETH_CALLDATA, toFunctionSelector('function depositEth()')) +}) + +test('portal: L1→L2 alias matches a real deposit (sender = alias(dest) for EOA self-deposit)', () => { + assert.equal(aliasL1Address(FIXTURE.dest), FIXTURE.aliasedSender) +}) + +test('portal: child EthDeposit tx hash derivation matches a real fill', () => { + const h = childEthDepositTxHash(4663n, FIXTURE.msgNum, FIXTURE.aliasedSender, FIXTURE.dest, FIXTURE.value) + assert.equal(h, FIXTURE.childTxHash) +}) + +test('portal: deposit receipt parses to the derived child hash', () => { + const packed = `0x${FIXTURE.dest.slice(2)}${pad(toHex(FIXTURE.value), { size: 32 }).slice(2)}` as Hex + const receipt = { + from: FIXTURE.dest, // EOA self-deposit: tx sender == destination + logs: [ + { + address: PORTAL_INBOX, + topics: [ + '0xff64905f73a67fb594e0f940a8075a860db489ad991e032f48c81123eb52d60b', + pad(toHex(FIXTURE.msgNum), { size: 32 }), + ], + data: encodeAbiParameters([{ type: 'bytes' }], [packed]), + }, + ], + } as unknown as TransactionReceipt + assert.equal(parseEthDepositReceipt(receipt), FIXTURE.childTxHash) +}) + +test('portal: quotes are lossless 1:1 with the measured ETA, deposits-from-Ethereum only', () => { + const leg = resolveIntent({ dir: 'in', token: ETH_OPTION, remote: ETH_REMOTE, amount: 123n }) + const q = quotePortal(leg, 123n) + assert.equal(q.outputAmount, 123n) + assert.equal(q.minOutput, 123n) + assert.equal(q.etaSec, PORTAL_ETA_SEC) + assert.equal(q.expiresAt, null) + assert.deepEqual(q.steps, [ + { kind: 'deposit', chainId: 1, to: PORTAL_INBOX, data: DEPOSIT_ETH_CALLDATA, value: 123n }, + ]) + // guards: no withdrawals, no non-Ethereum remotes, no ERC-20s + assert.throws(() => quotePortal(resolveIntent({ dir: 'out', token: ETH_OPTION, remote: ETH_REMOTE, amount: 1n }), 1n), BridgeQuoteError) + assert.throws(() => quotePortal(resolveIntent({ dir: 'in', token: ETH_OPTION, remote: BASE_REMOTE, amount: 1n }), 1n), BridgeQuoteError) + assert.throws(() => quotePortal(resolveIntent({ dir: 'in', token: USDG_OPTION, remote: ETH_REMOTE, amount: 1n }), 1n), BridgeQuoteError) +}) + +// ---- token discovery (same-token only, engine-reported support) ---- + +const RELAY_CHAINS_FIX: RelayChainsJson = { + chains: [ + { + id: 4663, + currency: { symbol: 'ETH', decimals: 18, supportsBridging: true }, + erc20Currencies: [ + { symbol: 'USDG', address: RH_USDG, decimals: 6, supportsBridging: true }, + { symbol: 'DEAD', address: '0x00000000000000000000000000000000000dead1' as Address, decimals: 18, supportsBridging: false }, + ], + }, + { id: 1, currency: { symbol: 'ETH', decimals: 18, supportsBridging: true } }, + { id: 8453, currency: { symbol: 'ETH', decimals: 18, supportsBridging: true } }, + ], +} + +const RELAY_CUR_FIX: Record = { + USDG: [ + { chainId: 4663, address: RH_USDG, symbol: 'USDG', decimals: 6, metadata: { verified: true } }, + // mainnet USDG is listed UNVERIFIED by relay — trusted only via the across route address + { chainId: 1, address: L1_USDG, symbol: 'USDG', decimals: 6, metadata: { verified: false } }, + ], + WETH: [ + { chainId: 4663, address: RH_WETH, symbol: 'WETH', decimals: 18, metadata: { verified: false } }, + { chainId: 1, address: L1_WETH, symbol: 'WETH', decimals: 18, metadata: { verified: true } }, + // scam twin on mainnet — must never be picked + { chainId: 1, address: '0x00000000000000000000000000000000000dead2' as Address, symbol: 'WETH', decimals: 18, metadata: { verified: false } }, + ], +} + +const ACROSS_ROUTES_FIX: AcrossRouteJson[] = [ + // ethereum pair + { originChainId: 1, originToken: L1_WETH, destinationChainId: 4663, destinationToken: NATIVE_SENTINEL, originTokenSymbol: 'ETH', destinationTokenSymbol: 'ETH', isNative: true }, + { originChainId: 1, originToken: L1_WETH, destinationChainId: 4663, destinationToken: RH_WETH, originTokenSymbol: 'WETH', destinationTokenSymbol: 'WETH' }, + // across labels mainnet USDG "USDG-MAINNET" — still the same token + { originChainId: 1, originToken: L1_USDG, destinationChainId: 4663, destinationToken: RH_USDG, originTokenSymbol: 'USDG-MAINNET', destinationTokenSymbol: 'USDG' }, + // cross-token legs (dropped by product decision): USDC→USDG and USDG→USDC + { originChainId: 1, originToken: L1_USDC, destinationChainId: 4663, destinationToken: RH_USDG, originTokenSymbol: 'USDC', destinationTokenSymbol: 'USDG' }, + { originChainId: 4663, originToken: RH_USDG, destinationChainId: 1, destinationToken: L1_USDC, originTokenSymbol: 'USDG', destinationTokenSymbol: 'USDC' }, + // base pair: only ETH + cross-token USDC→USDG + { originChainId: 8453, originToken: '0x4200000000000000000000000000000000000006' as Address, destinationChainId: 4663, destinationToken: NATIVE_SENTINEL, originTokenSymbol: 'ETH', destinationTokenSymbol: 'ETH', isNative: true }, + { originChainId: 8453, originToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as Address, destinationChainId: 4663, destinationToken: RH_USDG, originTokenSymbol: 'USDC', destinationTokenSymbol: 'USDG' }, +] + +test('discovery: ethereum pair = ETH(portal+relay+across) + USDG(relay+across) + WETH(across only)', () => { + const list = mergeTokenSupports({ + remoteChainId: 1, + relayChains: RELAY_CHAINS_FIX, + relayCurrencies: RELAY_CUR_FIX, + acrossRoutes: ACROSS_ROUTES_FIX, + }) + assert.deepEqual( + list.map((s) => s.symbol), + ['ETH', 'USDG', 'WETH'], + ) + const [eth, usdg, weth] = list + assert.deepEqual({ relay: eth.relay, across: eth.across, portal: eth.portal }, { relay: true, across: true, portal: true }) + assert.equal(usdg.remoteToken, L1_USDG) // route-confirmed, NOT the USDC leg + assert.deepEqual({ relay: usdg.relay, across: usdg.across, portal: usdg.portal }, { relay: true, across: true, portal: false }) + assert.equal(weth.remoteToken, L1_WETH) // verified twin wins, scam twin ignored + assert.deepEqual({ relay: weth.relay, across: weth.across, portal: weth.portal }, { relay: false, across: true, portal: false }) + // direction resolution: portal is deposit-only + assert.deepEqual(providersFor(eth, 'in'), ['portal', 'relay', 'across']) + assert.deepEqual(providersFor(eth, 'out'), ['relay', 'across']) +}) + +test('discovery: cross-token USDC→USDG legs are gone; base pair has no USDG', () => { + const list = mergeTokenSupports({ + remoteChainId: 8453, + relayChains: RELAY_CHAINS_FIX, + relayCurrencies: RELAY_CUR_FIX, + acrossRoutes: ACROSS_ROUTES_FIX, + }) + // USDG exists on the 4663 side but base has no same-token counterpart → + // it must NOT appear (the old USDC→USDG mapping is exactly what was removed) + assert.deepEqual( + list.map((s) => s.symbol), + ['ETH'], + ) + assert.equal(list[0].portal, false) // canonical bridge pairs with Ethereum only +}) + +test('discovery: a failed live Inbox verification demotes the canonical route', () => { + const list = mergeTokenSupports({ + remoteChainId: 1, + relayChains: RELAY_CHAINS_FIX, + relayCurrencies: RELAY_CUR_FIX, + acrossRoutes: ACROSS_ROUTES_FIX, + portalOk: false, + }) + const eth = list.find((s) => s.symbol === 'ETH')! + assert.equal(eth.portal, false) + assert.equal(eth.relay, true) // the other engines are untouched +}) + +test('discovery: loose symbol guard accepts chain-suffixed labels, rejects different assets', () => { + assert.ok(sameSymbolLoose('USDG-MAINNET', 'USDG')) + assert.ok(sameSymbolLoose('USDG', 'USDG')) + assert.ok(!sameSymbolLoose('USDC', 'USDG')) + assert.ok(!sameSymbolLoose('WETH', 'ETH')) +}) + +// ---- pending transfers: conservative scheduling ---- + +const basePending: PendingTransfer = { + id: '0xdep', + provider: 'relay', + tracker: { provider: 'relay', requestId: '0xreq' }, + createdAt: 1_000_000, + etaSec: 5, + originChainId: 1, + destChainId: 4663, + symbol: 'ETH', + amountIn: '0.05', + expectedOut: '50000000000000000', + decimals: 18, + depositTxHash: '0xdep', + status: 'pending', +} + +test('pending: first check waits ~90% of ETA (min 8s), then slow cadence by speed class', () => { + // fast bridge (5s eta): first check at the 8s floor, then every 20s + assert.equal(nextCheckAt(basePending), 1_000_000 + 8_000) + assert.equal(nextCheckAt({ ...basePending, checkedAt: 1_010_000 }), 1_030_000) + // canonical (~600s): first check at 540s, then every 60s — a 10-min transfer + // costs a handful of status reads, not hundreds + const portal: PendingTransfer = { ...basePending, etaSec: 600 } + assert.equal(nextCheckAt(portal), 1_000_000 + 540_000) + assert.equal(nextCheckAt({ ...portal, checkedAt: 1_000_000 + 540_000 }), 1_000_000 + 600_000) +}) + +test('pending: short eta formatter is locale-neutral', () => { + assert.equal(fmtEtaShort(600), '~10m') + assert.equal(fmtEtaShort(7), '~7s') +}) + +test('pending: portal check resolves via child receipt probe; missing hash goes stale', async () => { + const portalT: PendingTransfer = { + ...basePending, + provider: 'portal', + tracker: { provider: 'portal', childTxHash: FIXTURE.childTxHash }, + } + const found = await checkPendingTransfer(portalT, async () => true) + assert.equal(found.status, 'filled') + assert.equal(found.fillTxHash, FIXTURE.childTxHash) + const notYet = await checkPendingTransfer(portalT, async () => false) + assert.equal(notYet.status, undefined) + assert.ok(typeof notYet.checkedAt === 'number') + const unparseable = await checkPendingTransfer( + { ...portalT, tracker: { provider: 'portal', childTxHash: null } }, + async () => true, + ) + assert.equal(unparseable.status, 'stale') +}) + +test('pending store: cross-tab writes merge instead of last-writer-wins', () => { + // node has no localStorage — install a stub BEFORE the store's first lazy read + const backing = new Map() + const stub = { + getItem: (k: string) => backing.get(k) ?? null, + setItem: (k: string, v: string) => void backing.set(k, v), + removeItem: (k: string) => void backing.delete(k), + } + const g = globalThis as { localStorage?: unknown } + g.localStorage = stub + const KEY = 'up33.bridgePending.v1' + const A: PendingTransfer = { ...basePending, id: 'A', depositTxHash: 'A' } + const B: PendingTransfer = { ...basePending, id: 'B', depositTxHash: 'B' } + try { + backing.set(KEY, JSON.stringify([A])) + assert.deepEqual(pendingBridges.get().map((e) => e.id), ['A']) // lazy first load + + // another tab fills A and adds B; this tab then applies a stale pending-check patch + backing.set(KEY, JSON.stringify([{ ...A, status: 'filled', fillTxHash: '0xf' }, B])) + pendingBridges.update('A', { checkedAt: 123, status: 'stale' }) + const merged = pendingBridges.get() + const a1 = merged.find((e) => e.id === 'A')! + assert.equal(a1.status, 'filled') // terminal state never regresses + assert.equal(a1.fillTxHash, '0xf') + assert.equal(a1.checkedAt, 123) // non-status patch still lands + assert.ok(merged.some((e) => e.id === 'B')) // the other tab's add survived + + // another tab dismisses B → our next mutation drops it too + backing.set(KEY, JSON.stringify([{ ...A, status: 'filled', fillTxHash: '0xf' }])) + pendingBridges.update('A', { checkedAt: 456 }) + assert.ok(!pendingBridges.get().some((e) => e.id === 'B')) + + // storage becomes unreadable → memory carries the session (never wiped) + g.localStorage = { + getItem: () => { + throw new Error('blocked') + }, + setItem: () => { + throw new Error('blocked') + }, + } + pendingBridges.update('A', { checkedAt: 789 }) + assert.equal(pendingBridges.get().find((e) => e.id === 'A')?.checkedAt, 789) + } finally { + delete g.localStorage + } +}) + +test('pending: relay/across status mapping', async () => { + const orig = globalThis.fetch + try { + globalThis.fetch = (async () => + new Response(JSON.stringify({ status: 'success', txHashes: ['0xa', '0xfill'] }), { status: 200 })) as typeof fetch + const r = await checkPendingTransfer(basePending, async () => false) + assert.equal(r.status, 'filled') + assert.equal(r.fillTxHash, '0xfill') + + globalThis.fetch = (async () => + new Response(JSON.stringify({ status: 'expired' }), { status: 200 })) as typeof fetch + const acrossT: PendingTransfer = { + ...basePending, + provider: 'across', + tracker: { provider: 'across', originChainId: 1, depositTxHash: '0xdep' }, + } + const a = await checkPendingTransfer(acrossT, async () => false) + assert.equal(a.status, 'refunded') + + // transient API failure → only checkedAt moves; cadence retries later + globalThis.fetch = (async () => { + throw new Error('network down') + }) as typeof fetch + const quiet = await checkPendingTransfer(basePending, async () => false) + assert.equal(quiet.status, undefined) + assert.ok(typeof quiet.checkedAt === 'number') + } finally { + globalThis.fetch = orig + } +}) diff --git a/src/lib/bridge/exec.ts b/src/lib/bridge/exec.ts new file mode 100644 index 0000000..0b91462 --- /dev/null +++ b/src/lib/bridge/exec.ts @@ -0,0 +1,103 @@ +// Bridge execution: send the provider-built origin-chain txs through the +// shared step() runner, then hand the transfer to the persistent pending +// registry (pending.ts). Fills are verified there on a conservative cadence — +// this function returns as soon as the deposit lands, and the wallet is +// switched back to Robinhood right away. +import type { Address, TransactionReceipt } from 'viem' +import { getChainId, sendTransaction, switchChain } from 'wagmi/actions' +import { CHAIN_ID } from '../../config/addresses' +import { explorerOf, type ResolvedIntent } from '../../config/bridge' +import { asConfiguredChain, wagmiConfig } from '../../config/wagmi' +import { t } from '../../i18n' +import { invalidateAll, shortErr, step } from '../tx' +import { txlog } from '../txlog' +import { fmtEtaShort, pendingBridges, type PendingTracker } from './pending' +import { parseEthDepositReceipt } from './portal' +import type { BridgeQuote } from './types' + +/** 'sent' = deposit confirmed and the transfer is tracked as pending */ +export type BridgeOutcome = 'sent' | null + +/** live position inside an executing bridge, for inline UI progress */ +export type BridgeStage = 'approve' | 'deposit' + +async function ensureWalletChain(chainId: number): Promise { + const target = asConfiguredChain(chainId) + if (getChainId(wagmiConfig) === target) return true + try { + await switchChain(wagmiConfig, { chainId: target }) + return true + } catch (e) { + txlog.push('err', t('bridge.switchFailed', { err: shortErr(e) })) + return false + } +} + +function buildTracker(quote: BridgeQuote, depositRcpt: TransactionReceipt): PendingTracker { + if (quote.tracker.provider === 'relay') return quote.tracker + if (quote.tracker.provider === 'across') + return { provider: 'across', originChainId: quote.tracker.originChainId, depositTxHash: depositRcpt.transactionHash } + return { provider: 'portal', childTxHash: parseEthDepositReceipt(depositRcpt) } +} + +export async function executeBridge( + quote: BridgeQuote, + sender: Address, + ctx: { leg: ResolvedIntent; amountInStr: string; depositLabel: string }, + onStage?: (stage: BridgeStage) => void, +): Promise { + if (quote.expiresAt !== null && Date.now() / 1000 > quote.expiresAt - 30) { + txlog.push('err', t('bridge.quoteExpired')) + return null + } + let depositRcpt: TransactionReceipt | null = null + const originChain = quote.steps[0]?.chainId ?? CHAIN_ID + if (!(await ensureWalletChain(originChain))) return null + + for (const s of quote.steps) { + onStage?.(s.kind) + const rcpt = await step( + s.kind === 'approve' ? t('tx.approve', { sym: ctx.leg.inputSymbol }) : ctx.depositLabel, + () => + sendTransaction(wagmiConfig, { + account: sender, + to: s.to, + data: s.data, + value: s.value, + chainId: asConfiguredChain(s.chainId), + }), + { chainId: asConfiguredChain(s.chainId), explorer: explorerOf(s.chainId) }, + ) + if (!rcpt) return null + if (s.kind === 'deposit') depositRcpt = rcpt + } + if (!depositRcpt) return null + + pendingBridges.add({ + id: depositRcpt.transactionHash, + provider: quote.provider, + tracker: buildTracker(quote, depositRcpt), + createdAt: Date.now(), + etaSec: quote.etaSec, + originChainId: ctx.leg.originChainId, + destChainId: ctx.leg.destChainId, + symbol: ctx.leg.outputSymbol, + amountIn: ctx.amountInStr, + expectedOut: quote.outputAmount.toString(), + decimals: ctx.leg.outputDecimals, + depositTxHash: depositRcpt.transactionHash, + status: 'pending', + }) + txlog.push('info', t('bridge.pendingTracked', { eta: fmtEtaShort(quote.etaSec) })) + + // bring the wallet home after an off-Robinhood origin (best effort) + if (originChain !== CHAIN_ID) { + try { + await switchChain(wagmiConfig, { chainId: CHAIN_ID }) + } catch { + /* user declined — the header banner will offer the switch */ + } + } + invalidateAll() + return 'sent' +} diff --git a/src/lib/bridge/pending.ts b/src/lib/bridge/pending.ts new file mode 100644 index 0000000..d799c2b --- /dev/null +++ b/src/lib/bridge/pending.ts @@ -0,0 +1,203 @@ +// Persistent registry of in-flight bridge transfers, polled CONSERVATIVELY. +// Product decision (2026-07-18): after the deposit confirms we do NOT sit in a +// tight status loop — the transfer becomes a PENDING entry on the bridge tab, +// the first status check waits until ~90% of the provider's ETA, and follow-ups +// run on a slow cadence (20s for fast engines, 60s for the ~10-min canonical +// bridge). Entries survive reloads via localStorage; after an hour still +// pending they go 'stale' (auto-polling stops, manual recheck stays). +import type { Hex } from 'viem' +import { fetchAcrossStatus } from './across' +import { fetchRelayStatus } from './relay' +import type { BridgeProviderId } from './types' + +export type PendingTracker = + | { provider: 'relay'; requestId: string } + | { provider: 'across'; originChainId: number; depositTxHash: string } + /** null childTxHash = receipt parse failed; untrackable, surfaces as stale */ + | { provider: 'portal'; childTxHash: Hex | null } + +export type PendingStatus = 'pending' | 'filled' | 'refunded' | 'failed' | 'stale' + +export type PendingTransfer = { + /** deposit tx hash — unique per transfer */ + id: string + provider: BridgeProviderId + tracker: PendingTracker + /** ms epoch of the deposit confirmation */ + createdAt: number + etaSec: number + originChainId: number + destChainId: number + /** same-token model: one symbol describes both legs */ + symbol: string + /** origin-side amount, display units */ + amountIn: string + /** expected destination amount, raw units as string */ + expectedOut: string + decimals: number + depositTxHash: string + status: PendingStatus + fillTxHash?: string + checkedAt?: number +} + +const KEY = 'up33.bridgePending.v1' +const MAX = 20 + +/** null = storage unavailable (blocked/absent) — distinct from an empty list, + * so an unreadable disk is never mistaken for "another tab dismissed all" */ +function load(): PendingTransfer[] | null { + try { + const raw = localStorage.getItem(KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (t): t is PendingTransfer => + !!t && + typeof (t as PendingTransfer).id === 'string' && + typeof (t as PendingTransfer).createdAt === 'number' && + ['pending', 'filled', 'refunded', 'failed', 'stale'].includes((t as PendingTransfer).status) && + typeof (t as PendingTransfer).tracker === 'object', + ) + } catch { + return null + } +} + +function save(list: PendingTransfer[]) { + try { + localStorage.setItem(KEY, JSON.stringify(list)) + } catch { + /* storage blocked/full — the in-memory list still works this session */ + } +} + +let entries: PendingTransfer[] | null = null // lazy so module load never touches storage +const subs = new Set<() => void>() +let storageHooked = false + +const isTerminal = (s: PendingStatus) => s === 'filled' || s === 'refunded' || s === 'failed' + +function all(): PendingTransfer[] { + entries ??= load() ?? [] + return entries +} + +/** re-sync with storage before mutating: several tabs share this store, and + * whole-list last-writer-wins would drop the other tab's entries or status + * advances. Disk is the shared truth: disk-only entries are another tab's + * adds (keep), memory-only entries were dismissed there (drop), and an entry + * present in both keeps its most-advanced form. */ +function fresh(): PendingTransfer[] { + const disk = load() + const mem = entries + if (disk === null) return mem ?? [] // unreadable storage — memory carries on + if (mem === null) return disk + return disk.map((d) => { + const m = mem.find((e) => e.id === d.id) + if (!m) return d + if (isTerminal(d.status) !== isTerminal(m.status)) return isTerminal(d.status) ? d : m + return (m.checkedAt ?? 0) > (d.checkedAt ?? 0) ? m : d + }) +} + +function emit() { + save(all()) + subs.forEach((f) => f()) +} + +export const pendingBridges = { + add(t: PendingTransfer) { + const base = fresh() + entries = base.some((e) => e.id === t.id) ? base : [t, ...base].slice(0, MAX) + emit() + }, + update(id: string, patch: Partial) { + entries = fresh().map((e) => { + if (e.id !== id) return e + const next = { ...e, ...patch } + // a terminal state never regresses (e.g. a stale verdict computed from a + // pre-fill snapshot, or a slow check racing another tab's fill) + if (isTerminal(e.status) && patch.status && !isTerminal(patch.status)) next.status = e.status + return next + }) + emit() + }, + dismiss(id: string) { + entries = fresh().filter((e) => e.id !== id) + emit() + }, + get(): PendingTransfer[] { + return all() + }, + subscribe(f: () => void): () => void { + // cross-tab sync: adopt another tab's write when it lands ('storage' only + // fires in OTHER tabs — exactly the direction we need) + if (!storageHooked && typeof window !== 'undefined') { + storageHooked = true + window.addEventListener('storage', (ev) => { + if (ev.key !== KEY) return + entries = load() ?? entries + subs.forEach((s) => s()) + }) + } + subs.add(f) + return () => subs.delete(f) + }, +} + +// ---- conservative scheduling (pure, unit-tested) ---- + +/** pending this long → 'stale': auto-polling stops, manual recheck remains */ +export const PENDING_STALE_MS = 60 * 60_000 + +/** first check ≈90% of ETA (never under 8s), then 20s/60s cadence by speed class */ +export function nextCheckAt(t: PendingTransfer): number { + const eta = t.etaSec * 1000 + const first = t.createdAt + Math.max(Math.round(eta * 0.9), 8_000) + if (!t.checkedAt) return first + const cadence = eta >= 120_000 ? 60_000 : 20_000 + return Math.max(first, t.checkedAt + cadence) +} + +export const isStale = (t: PendingTransfer, now: number) => now - t.createdAt > PENDING_STALE_MS + +/** locale-neutral short ETA for terminal rows: 600 → "~10m", 7 → "~7s" */ +export const fmtEtaShort = (sec: number): string => + sec >= 90 ? `~${Math.round(sec / 60)}m` : `~${Math.max(1, Math.round(sec))}s` + +// ---- status checking (portal receipt probe injected: wagmi stays out of here) ---- + +export type PortalReceiptProbe = (childTxHash: Hex) => Promise + +/** one status check → patch to merge (always bumps checkedAt; transient API + * errors resolve to just that, so the cadence retries silently) */ +export async function checkPendingTransfer( + t: PendingTransfer, + portalReceipt: PortalReceiptProbe, +): Promise> { + const base: Partial = { checkedAt: Date.now() } + try { + if (t.tracker.provider === 'relay') { + const s = await fetchRelayStatus(t.tracker.requestId) + if (s.status === 'success') return { ...base, status: 'filled', fillTxHash: s.txHashes?.at(-1) } + if (s.status === 'refund') return { ...base, status: 'refunded' } + if (s.status === 'failure') return { ...base, status: 'failed' } + return base + } + if (t.tracker.provider === 'across') { + const s = await fetchAcrossStatus(t.tracker.originChainId, t.tracker.depositTxHash) + if (s.status === 'filled') return { ...base, status: 'filled', fillTxHash: s.fillTx } + if (s.status === 'refunded' || s.status === 'expired') return { ...base, status: 'refunded' } + return base + } + // portal: the child tx hash was derived from the deposit receipt — arrival + // is one receipt lookup on our own RPC + if (t.tracker.childTxHash === null) return { ...base, status: 'stale' } + const found = await portalReceipt(t.tracker.childTxHash) + return found ? { ...base, status: 'filled', fillTxHash: t.tracker.childTxHash } : base + } catch { + return base + } +} diff --git a/src/lib/bridge/portal.ts b/src/lib/bridge/portal.ts new file mode 100644 index 0000000..2c14b58 --- /dev/null +++ b/src/lib/bridge/portal.ts @@ -0,0 +1,105 @@ +// Canonical Arbitrum bridge ("portal") provider: native ETH deposits +// Ethereum → Robinhood via Inbox.depositEth(). Lossless 1:1 — no solver, no +// bridge fee, only L1 gas — but slow (real deposits measured 484–689s on +// 2026-07-18, so it quotes PORTAL_ETA_SEC). Scope is deliberately narrow: +// - ETH only. ERC-20s canonically bridge into the gateway's OWN wrapped +// tokens: verified on-chain that calculateL2TokenAddress(mainnet USDG) +// != the real Robinhood USDG (which is LayerZero-OFT-issued instead). +// - deposits only. Canonical withdrawals sit out the rollup challenge +// period (days) — the external portal link stays the escape hatch. +// Fill tracking needs no status API at all: an EthDeposit's child tx hash is +// derivable from the deposit receipt, so "did it arrive" is one receipt read +// on our own Robinhood RPC. Formula validated against 3 real deposit pairs. +import { + concatHex, + decodeAbiParameters, + getAddress, + keccak256, + pad, + toHex, + toRlp, + type Address, + type Hex, + type TransactionReceipt, +} from 'viem' +import { + NATIVE_SENTINEL, + PORTAL_ETA_SEC, + PORTAL_INBOX, + PORTAL_PARENT_CHAIN_ID, + type ResolvedIntent, +} from '../../config/bridge' +import { CHAIN_ID } from '../../config/addresses' +import { BridgeQuoteError, type BridgeQuote } from './types' + +/** Inbox.depositEth() — credits msg.sender (its alias for contract wallets) on the child chain */ +export const DEPOSIT_ETH_CALLDATA = '0x439370b1' as Hex + +/** topic0 of Inbox's InboxMessageDelivered(uint256 indexed messageNum, bytes data) */ +const INBOX_MSG_TOPIC = '0xff64905f73a67fb594e0f940a8075a860db489ad991e032f48c81123eb52d60b' + +export function quotePortal(leg: ResolvedIntent, amount: bigint): BridgeQuote { + if ( + leg.originChainId !== PORTAL_PARENT_CHAIN_ID || + leg.destChainId !== CHAIN_ID || + leg.inputToken.toLowerCase() !== NATIVE_SENTINEL.toLowerCase() + ) { + throw new BridgeQuoteError('canonical bridge only deposits native ETH from Ethereum') + } + return { + provider: 'portal', + outputAmount: amount, + minOutput: amount, + etaSec: PORTAL_ETA_SEC, + steps: [ + { + kind: 'deposit', + chainId: PORTAL_PARENT_CHAIN_ID, + to: PORTAL_INBOX, + data: DEPOSIT_ETH_CALLDATA, + value: amount, + }, + ], + tracker: { provider: 'portal' }, + expiresAt: null, + } +} + +/** L1→L2 sender aliasing (AddressAliasHelper) — the Inbox applies it to every + * delayed message's sender, and the child EthDeposit tx carries the alias */ +export function aliasL1Address(addr: Address): Address { + const OFFSET = 0x1111000000000000000000000000000000001111n + return getAddress(toHex((BigInt(addr) + OFFSET) & ((1n << 160n) - 1n), { size: 20 })) +} + +const rlpNum = (x: bigint): Hex => (x === 0n ? '0x' : toHex(x)) + +/** child-chain tx hash of an EthDeposit message: keccak256(0x64 ‖ rlp([chainId, + * msgNum₃₂, aliasedSender, dest, value])) — 0x64 = ArbitrumDepositTx type. + * Validated 3/3 against real Ethereum→Robinhood deposits (2026-07-18). */ +export function childEthDepositTxHash( + chainId: bigint, + messageNum: bigint, + aliasedSender: Address, + dest: Address, + value: bigint, +): Hex { + return keccak256( + concatHex(['0x64', toRlp([rlpNum(chainId), pad(toHex(messageNum), { size: 32 }), aliasedSender, dest, rlpNum(value)])]), + ) +} + +/** derive the child tx hash from a confirmed depositEth receipt: messageNum + + * packed (dest ‖ value) come from the Inbox's InboxMessageDelivered log, the + * sender is the alias of the tx sender (the Inbox aliases unconditionally) */ +export function parseEthDepositReceipt(receipt: TransactionReceipt): Hex | null { + const log = receipt.logs.find( + (l) => l.address.toLowerCase() === PORTAL_INBOX.toLowerCase() && l.topics[0] === INBOX_MSG_TOPIC, + ) + if (!log || !log.topics[1] || !receipt.from) return null + const [packed] = decodeAbiParameters([{ type: 'bytes' }], log.data) + if (packed.length < 2 + 40) return null + const dest = getAddress(`0x${packed.slice(2, 42)}`) + const value = BigInt(`0x${packed.slice(42) || '0'}`) + return childEthDepositTxHash(BigInt(CHAIN_ID), BigInt(log.topics[1]), aliasL1Address(receipt.from), dest, value) +} diff --git a/src/lib/bridge/relay.ts b/src/lib/bridge/relay.ts new file mode 100644 index 0000000..814c1c0 --- /dev/null +++ b/src/lib/bridge/relay.ts @@ -0,0 +1,125 @@ +// Relay (relay.link) quote + status. Keyless, CORS-open; quotes are POSTed and +// return ready-to-send origin-chain txs. Fee note: appFees takes BPS AS A +// STRING ("10" = 0.1%) and accrues off-chain as USDC (claim on Base is free) — +// see docs/bridge-research.md. +import type { Address } from 'viem' +import type { ResolvedIntent } from '../../config/bridge' +import { BridgeQuoteError, type BridgeFee, type BridgeQuote, type BridgeStep } from './types' + +const RELAY_API = 'https://api.relay.link' +/** placeholder payer for pre-connect display quotes (Relay requires a user) */ +export const QUOTE_PLACEHOLDER = '0x000000000000000000000000000000000000dEaD' as Address + +/** Relay's appFees unit is BPS as a string: 10 bps -> "10" */ +export const relayAppFee = (bps: number): string => String(bps) + +type RelayTxData = { + to: Address + data: `0x${string}` + value?: string | null + chainId: number +} +type RelayStep = { + id: string + kind: string + requestId?: string + items?: { data?: RelayTxData; check?: { endpoint?: string } | null }[] +} +export type RelayQuoteJson = { + steps?: RelayStep[] + details?: { + currencyOut?: { amount?: string; minimumAmount?: string } + timeEstimate?: number + } + message?: string + code?: string +} + +/** pure mapper — throws BridgeQuoteError on shapes we refuse to execute */ +export function mapRelayQuote(json: RelayQuoteJson): BridgeQuote { + const steps: BridgeStep[] = [] + let requestId: string | null = null + for (const s of json.steps ?? []) { + if (s.kind !== 'transaction') { + throw new BridgeQuoteError(`relay quote needs unsupported step kind "${s.kind}"`) + } + requestId ??= s.requestId ?? null + for (const item of s.items ?? []) { + const d = item.data + if (!d) continue + steps.push({ + kind: s.id === 'approve' ? 'approve' : 'deposit', + chainId: d.chainId, + to: d.to, + data: d.data, + value: BigInt(d.value ?? '0'), + }) + } + } + const out = json.details?.currencyOut + if (!steps.length || !requestId || !out?.amount) { + throw new BridgeQuoteError('relay quote response is missing steps/output') + } + return { + provider: 'relay', + outputAmount: BigInt(out.amount), + minOutput: BigInt(out.minimumAmount ?? out.amount), + etaSec: json.details?.timeEstimate ?? 0, + steps, + tracker: { provider: 'relay', requestId }, + expiresAt: null, // relay re-validates at execution; minOutput guards the fill + } +} + +export async function quoteRelay( + leg: ResolvedIntent, + amount: bigint, + fee: BridgeFee, + user: Address | null, + signal?: AbortSignal, +): Promise { + const payer = user ?? QUOTE_PLACEHOLDER + const res = await fetch(`${RELAY_API}/quote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: signal ?? null, + body: JSON.stringify({ + user: payer, + recipient: payer, + // research: refunds are NOT automatic unless refundTo is explicit + refundTo: payer, + originChainId: leg.originChainId, + destinationChainId: leg.destChainId, + originCurrency: leg.inputToken, + destinationCurrency: leg.outputToken, + amount: amount.toString(), + tradeType: 'EXACT_INPUT', + referrer: 'lp-terminal', + // zero-fee mode omits appFees — don't bet on providers accepting "0" + ...(fee.bps > 0 ? { appFees: [{ recipient: fee.receiver, fee: relayAppFee(fee.bps) }] } : {}), + }), + }) + const json = (await res.json()) as RelayQuoteJson + if (!res.ok) { + throw new BridgeQuoteError(json.message ?? `relay quote failed (${res.status})`, json.code ?? null) + } + return mapRelayQuote(json) +} + +// ---- fill tracking ---- + +export type RelayStatus = + | 'waiting' + | 'pending' + | 'delayed' + | 'success' + | 'failure' + | 'refund' + | 'unknown' + +export async function fetchRelayStatus(requestId: string): Promise<{ status: RelayStatus; txHashes?: string[] }> { + const res = await fetch(`${RELAY_API}/intents/status?requestId=${requestId}`) + if (!res.ok) return { status: 'unknown' } + const json = (await res.json()) as { status?: string; txHashes?: string[] } + return { status: (json.status as RelayStatus) ?? 'unknown', txHashes: json.txHashes } +} diff --git a/src/lib/bridge/tokens.ts b/src/lib/bridge/tokens.ts new file mode 100644 index 0000000..ffa60c8 --- /dev/null +++ b/src/lib/bridge/tokens.ts @@ -0,0 +1,319 @@ +// Which tokens can bridge between Robinhood and a given remote — discovered +// from each engine's own support surface, never hardcoded, and SAME-TOKEN +// routes only (USDC→USDG cross-token legs were removed by product decision +// 2026-07-18; a route must pay out the symbol it took in). +// Sources per remote: +// Relay: GET /chains → Robinhood-side bridgeable set (currency + +// erc20Currencies with supportsBridging) and +// the remote's native-ETH support +// POST /currencies/v2 → remote-side ERC-20 membership + decimals +// Across: GET /available-routes → same-symbol pairs with BOTH addresses. +// The Swap API composes same-token exits even where no native route +// exists (probed 2026-07-18: USDG/ETH/WETH out all quote), so +// across is attempted whenever the symbol exists on both sides. +// Portal: canonical depositEth — native ETH, Ethereum→Robinhood only. +// Scam-token guard: a relay currencies/v2 match is only trusted when relay +// marks it verified OR its address is confirmed by an across route (term +// search returns fake same-symbol tokens — observed live for WETH). +import type { Address } from 'viem' +import { CHAIN_ID } from '../../config/addresses' +import { + PORTAL_INBOX, + PORTAL_PARENT_CHAIN_ID, + NATIVE_SENTINEL, + type BridgeDir, + type BridgeTokenOption, + type RemoteChain, +} from '../../config/bridge' +import type { BridgeProviderId } from './types' + +const RELAY_API = 'https://api.relay.link' +const ACROSS_API = 'https://app.across.to/api' + +export type RelayChainCurrency = { + symbol?: string + address?: Address + decimals?: number + supportsBridging?: boolean +} +export type RelayChainsJson = { + chains?: { id: number; currency?: RelayChainCurrency; erc20Currencies?: RelayChainCurrency[] }[] +} +export type RelayCurrencyV2 = { + chainId: number + address: Address + symbol: string + decimals: number + metadata?: { verified?: boolean } +} +export type AcrossRouteJson = { + originChainId: number + originToken: Address + destinationChainId: number + destinationToken: Address + originTokenSymbol: string + destinationTokenSymbol: string + isNative?: boolean +} + +/** direction-agnostic support facts for one same-token route */ +export type BridgeTokenSupport = { + symbol: string + decimals: number + robinhoodToken: Address + remoteToken: Address + relay: boolean + across: boolean + portal: boolean +} + +/** provider order here is only the pre-quote render order — the UI re-sorts by price */ +export function providersFor(s: BridgeTokenSupport, dir: BridgeDir): BridgeProviderId[] { + const out: BridgeProviderId[] = [] + if (s.portal && dir === 'in') out.push('portal') + if (s.relay) out.push('relay') + if (s.across) out.push('across') + return out +} + +export function toTokenOption(s: BridgeTokenSupport, dir: BridgeDir): BridgeTokenOption { + return { + symbol: s.symbol, + decimals: s.decimals, + robinhoodToken: s.robinhoodToken, + remoteToken: s.remoteToken, + providers: providersFor(s, dir), + } +} + +const eq = (a?: string, b?: string) => !!a && !!b && a.toLowerCase() === b.toLowerCase() + +/** same-token symbol guard for across route labels: across suffixes chain + * variants ("USDG-MAINNET" is mainnet USDG), so accept exact or dash-suffixed + * forms while still rejecting different assets (USDC vs USDG) */ +export const sameSymbolLoose = (a: string, b: string) => a === b || a.startsWith(`${b}-`) || b.startsWith(`${a}-`) + +/** pure merge over the raw source payloads — unit-tested against live captures */ +export function mergeTokenSupports(args: { + remoteChainId: number + relayChains: RelayChainsJson | null + /** currencies/v2 lookups (queried with both chain ids), keyed by symbol */ + relayCurrencies: Record + acrossRoutes: AcrossRouteJson[] | null + /** live Inbox-bytecode verification result; defaults to the parent-chain predicate */ + portalOk?: boolean +}): BridgeTokenSupport[] { + const { remoteChainId, relayChains, relayCurrencies, acrossRoutes } = args + const relayHome = relayChains?.chains?.find((c) => c.id === CHAIN_ID) + const relayRemote = relayChains?.chains?.find((c) => c.id === remoteChainId) + + // across: rows for this pair, either direction (same-token filtering happens + // per candidate by ADDRESS, with a loose-symbol guard against cross-token rows) + const pairRoutes = (acrossRoutes ?? []).filter( + (r) => + (r.originChainId === remoteChainId && r.destinationChainId === CHAIN_ID) || + (r.originChainId === CHAIN_ID && r.destinationChainId === remoteChainId), + ) + + const out: BridgeTokenSupport[] = [] + + // ---- native ETH ---- + const acrossEth = pairRoutes.some((r) => r.isNative && sameSymbolLoose(r.originTokenSymbol, r.destinationTokenSymbol)) + const relayEth = + relayHome?.currency?.symbol === 'ETH' && + relayHome.currency.supportsBridging === true && + relayRemote?.currency?.symbol === 'ETH' && + relayRemote.currency.supportsBridging === true + const portalEth = args.portalOk ?? remoteChainId === PORTAL_PARENT_CHAIN_ID + if (acrossEth || relayEth || portalEth) { + out.push({ + symbol: 'ETH', + decimals: 18, // native-currency constant on every leg we pair + robinhoodToken: NATIVE_SENTINEL, + remoteToken: NATIVE_SENTINEL, + relay: !!relayEth, + across: acrossEth, + portal: portalEth, + }) + } + + // ---- ERC-20 candidates: relay's bridgeable home set ∪ across's 4663-side + // tokens. Identity is ADDRESS-first: the candidate's Robinhood-side address + // anchors both engines' data, and route symbols only act as a cross-token + // guard (rejects USDC→USDG while keeping USDG-MAINNET→USDG). + const relayHomeErc20 = (relayHome?.erc20Currencies ?? []).filter( + (c) => c.symbol && c.address && c.supportsBridging !== false, + ) + type Candidate = { symbol: string; homeAddr: Address; relayHomeDec?: number; relayHome: boolean } + const cands = new Map() + for (const c of relayHomeErc20) { + cands.set((c.address as Address).toLowerCase(), { + symbol: c.symbol as string, + homeAddr: c.address as Address, + relayHomeDec: c.decimals, + relayHome: true, + }) + } + for (const r of pairRoutes) { + if (r.isNative) continue + const side = + r.destinationChainId === CHAIN_ID + ? { addr: r.destinationToken, symbol: r.destinationTokenSymbol } + : { addr: r.originToken, symbol: r.originTokenSymbol } + if (side.symbol === 'ETH') continue + const k = side.addr.toLowerCase() + if (!cands.has(k)) cands.set(k, { symbol: side.symbol, homeAddr: side.addr, relayHome: false }) + } + + for (const cand of cands.values()) { + const inRow = pairRoutes.find( + (r) => + !r.isNative && + r.destinationChainId === CHAIN_ID && + eq(r.destinationToken, cand.homeAddr) && + sameSymbolLoose(r.originTokenSymbol, cand.symbol), + ) + const outRow = pairRoutes.find( + (r) => + !r.isNative && + r.originChainId === CHAIN_ID && + eq(r.originToken, cand.homeAddr) && + sameSymbolLoose(r.destinationTokenSymbol, cand.symbol), + ) + const acrossRemoteAddr = inRow?.originToken ?? outRow?.destinationToken + + const lookup = relayCurrencies[cand.symbol] ?? [] + const exact = lookup.filter((c) => c.symbol === cand.symbol) + const trusted = (c: RelayCurrencyV2, confirmAddr?: Address) => + c.metadata?.verified === true || (confirmAddr !== undefined && eq(c.address, confirmAddr)) + // remote-side identity: across route wins; else a unique trusted relay match + const remoteTrusted = exact.filter((c) => c.chainId === remoteChainId && trusted(c, acrossRemoteAddr)) + const remoteAddr = acrossRemoteAddr ?? (remoteTrusted.length === 1 ? remoteTrusted[0].address : undefined) + if (!remoteAddr) continue + + // decimals must be discoverable on both sides and equal — never guessed + const homeDec = + cand.relayHomeDec ?? exact.find((c) => c.chainId === CHAIN_ID && eq(c.address, cand.homeAddr))?.decimals + const remoteDec = exact.find((c) => c.chainId === remoteChainId && eq(c.address, remoteAddr))?.decimals + if (homeDec === undefined || remoteDec === undefined || homeDec !== remoteDec) continue + + const relaySupported = + cand.relayHome && + exact.some((c) => c.chainId === remoteChainId && eq(c.address, remoteAddr) && trusted(c, acrossRemoteAddr)) + const acrossSupported = acrossRemoteAddr !== undefined + if (!relaySupported && !acrossSupported) continue + + out.push({ + symbol: cand.symbol, + decimals: homeDec, + robinhoodToken: cand.homeAddr, + remoteToken: remoteAddr, + relay: relaySupported, + across: acrossSupported, + portal: false, + }) + } + + // stable order for the dropdown: native first, then alphabetical + return out.sort((a, b) => (a.symbol === 'ETH' ? -1 : b.symbol === 'ETH' ? 1 : a.symbol.localeCompare(b.symbol))) +} + +// ---- fetch layer (shared payloads memoized for the session) ---- + +let chainsMemo: Promise | null = null +let routesMemo: Promise | null = null + +function fetchRelayChains(): Promise { + chainsMemo ??= fetch(`${RELAY_API}/chains`) + .then((r) => { + if (!r.ok) throw new Error(`relay chains ${r.status}`) + return r.json() as Promise + }) + .catch((e) => { + chainsMemo = null // do not cache failures + throw e + }) + return chainsMemo +} + +function fetchAcrossRoutes(): Promise { + routesMemo ??= fetch(`${ACROSS_API}/available-routes`) + .then((r) => { + if (!r.ok) throw new Error(`across routes ${r.status}`) + return r.json() as Promise + }) + .catch((e) => { + routesMemo = null + throw e + }) + return routesMemo +} + +/** the canonical bridge has no support API — its availability claim is checked + * against the chain itself (Inbox bytecode on the parent chain's default + * public RPC). Fail-open on RPC trouble: a transient outage must not hide the + * route; only a positive "no code at that address" demotes it. */ +async function verifyPortalInbox(remote: RemoteChain, signal?: AbortSignal): Promise { + if (remote.chain.id !== PORTAL_PARENT_CHAIN_ID) return false + const rpc = remote.chain.rpcUrls.default.http[0] + if (!rpc) return true + try { + const res = await fetch(rpc, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: signal ?? null, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getCode', params: [PORTAL_INBOX, 'latest'] }), + }) + const json = (await res.json()) as { result?: unknown } + if (typeof json.result !== 'string') return true + return json.result.length > 2 // '0x' = genuinely no contract there + } catch { + return true + } +} + +async function fetchRelayCurrencies(chainIds: number[], term: string, signal?: AbortSignal): Promise { + const res = await fetch(`${RELAY_API}/currencies/v2`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: signal ?? null, + body: JSON.stringify({ chainIds, term, limit: 30 }), + }) + if (!res.ok) throw new Error(`relay currencies ${res.status}`) + return (await res.json()) as RelayCurrencyV2[] +} + +/** discovery entrypoint — degrades per-source (one engine down only narrows + * the list; both sources down throws so the UI can offer a retry) */ +export async function fetchBridgeTokens(remote: RemoteChain, signal?: AbortSignal): Promise { + const [chainsR, routesR] = await Promise.allSettled([fetchRelayChains(), fetchAcrossRoutes()]) + const relayChains = chainsR.status === 'fulfilled' ? chainsR.value : null + const acrossRoutes = routesR.status === 'fulfilled' ? routesR.value : null + if (!relayChains && !acrossRoutes) throw new Error('bridge token discovery failed — both engines unreachable') + + const remoteChainId = remote.chain.id + const relayHome = relayChains?.chains?.find((c) => c.id === CHAIN_ID) + // symbols needing a currencies/v2 lookup = every 4663-side ERC-20 candidate + const symbols = new Set( + (relayHome?.erc20Currencies ?? []) + .filter((c) => c.symbol && c.supportsBridging !== false) + .map((c) => c.symbol as string), + ) + for (const r of acrossRoutes ?? []) { + if (r.isNative) continue + if (r.originChainId === remoteChainId && r.destinationChainId === CHAIN_ID && r.destinationTokenSymbol !== 'ETH') + symbols.add(r.destinationTokenSymbol) + if (r.originChainId === CHAIN_ID && r.destinationChainId === remoteChainId && r.originTokenSymbol !== 'ETH') + symbols.add(r.originTokenSymbol) + } + + const relayCurrencies: Record = {} + const [portalOk] = await Promise.all([ + verifyPortalInbox(remote, signal), + ...[...symbols].map(async (sym) => { + relayCurrencies[sym] = await fetchRelayCurrencies([CHAIN_ID, remoteChainId], sym, signal).catch(() => []) + }), + ]) + + return mergeTokenSupports({ remoteChainId, relayChains, relayCurrencies, acrossRoutes, portalOk }) +} diff --git a/src/lib/bridge/types.ts b/src/lib/bridge/types.ts new file mode 100644 index 0000000..4416b85 --- /dev/null +++ b/src/lib/bridge/types.ts @@ -0,0 +1,50 @@ +import type { Address, Hex } from 'viem' + +export type BridgeProviderId = 'relay' | 'across' | 'portal' + +/** injected by the caller (config/env bridgeFee) so provider modules stay + * env-free and unit-testable outside vite */ +export type BridgeFee = { bps: number; receiver: Address } + +/** one pre-built origin-chain transaction from a provider quote */ +export type BridgeStep = { + kind: 'approve' | 'deposit' + chainId: number + to: Address + data: Hex + value: bigint +} + +/** how to poll fill status once the deposit tx is confirmed (the portal's + * child tx hash only becomes derivable from the deposit receipt) */ +export type BridgeTracker = + | { provider: 'relay'; requestId: string } + | { provider: 'across'; originChainId: number } + | { provider: 'portal' } + +export type BridgeQuote = { + provider: BridgeProviderId + /** destination-side amounts, output-token units (terminal fee, if any, already deducted) */ + outputAmount: bigint + minOutput: bigint + etaSec: number + steps: BridgeStep[] + tracker: BridgeTracker + /** epoch seconds after which the quote must not be executed (null = provider + * re-validates at fill time) */ + expiresAt: number | null +} + +/** provider error with the upstream machine code preserved for UI mapping */ +export class BridgeQuoteError extends Error { + code: string | null + constructor(message: string, code: string | null = null) { + super(message) + this.code = code + } +} + +export type BridgeQuoteState = { + quote: BridgeQuote | null + error: BridgeQuoteError | null +} diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..6c59f76 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,40 @@ +/** + * Copy text, reporting whether it actually landed. + * + * `navigator.clipboard` needs a secure context — https or localhost. That + * covers the deployed site and `npm run dev`, but NOT a phone pointed at the + * dev server over the LAN (http://192.168.x.x), where the API is simply + * undefined. The textarea path is the fallback for exactly that case, and the + * boolean matters: a copy button that flashes "COPIED" without copying is worse + * than one that admits it failed, because the address the user then pastes is + * whatever happened to be in the clipboard already. + */ +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return true + } + } catch { + /* permission denied or no clipboard — fall through to the legacy path */ + } + // `finally`, not a trailing remove(): execCommand THROWS rather than + // returning false in some browsers (sandboxed frames, denied permission), + // and every throw used to strand its textarea in the document forever + let ta: HTMLTextAreaElement | null = null + try { + ta = document.createElement('textarea') + ta.value = text + ta.setAttribute('readonly', '') + // off-screen but still selectable; `display:none` would not be + ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;' + document.body.appendChild(ta) + ta.select() + ta.setSelectionRange(0, text.length) // iOS ignores select() on readonly + return document.execCommand('copy') + } catch { + return false + } finally { + ta?.remove() + } +} diff --git a/src/lib/dexscreener.test.ts b/src/lib/dexscreener.test.ts new file mode 100644 index 0000000..223ad6d --- /dev/null +++ b/src/lib/dexscreener.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { dsPairUrl } from './dexscreener' + +const POOL = '0x23D641FeCcD207E8794c593e8240444A0674C4Ba' // WETH/UP v3, checksummed +const LOWER = POOL.toLowerCase() // how the indexer API returns it + +test('builds the dexscreener pair page for a pool address', () => { + assert.equal(dsPairUrl(POOL), `https://dexscreener.com/robinhood/${POOL}`) + assert.equal(dsPairUrl(LOWER), `https://dexscreener.com/robinhood/${LOWER}`) +}) + +// The address is the only part of the URL that comes from data, so anything +// that is not exactly an address must produce NO link rather than a link +// somewhere unintended. Each of these is a way an attacker-controlled string +// re-points a naively-interpolated href. +test('refuses anything that is not a 20-byte hex address', () => { + for (const bad of [ + '', + 'javascript:alert(1)', + 'data:text/html,', + '//evil.tld', // protocol-relative: would leave dexscreener.com entirely + 'https://evil.tld', + '../../../evil', // path traversal off the /robinhood/ prefix + `${POOL}?to=evil.tld`, // query appended to the pair path + `${POOL}#@evil.tld`, + `${POOL}@evil.tld`, // userinfo trick + `${POOL}/../../evil`, + ` ${POOL}`, // leading space — \s must not be eaten by the anchors + `${POOL}\n`, + POOL.slice(0, -1), // 19.5 bytes + POOL + 'ab', // 21 bytes + POOL.replace('0x', ''), // no prefix + '0x' + 'g'.repeat(40), // non-hex + ]) + assert.equal(dsPairUrl(bad), null, `should have refused ${JSON.stringify(bad)}`) +}) + +// Belt to the regex's braces: whatever comes back must parse, and must parse +// as dexscreener over https. Nothing else is an acceptable place to send a user. +test('every URL it does build is https://dexscreener.com', () => { + for (const addr of [POOL, LOWER, '0x' + '0'.repeat(40), '0x' + 'f'.repeat(40)]) { + const u = new URL(dsPairUrl(addr)!) + assert.equal(u.protocol, 'https:') + assert.equal(u.host, 'dexscreener.com') + assert.equal(u.pathname, `/robinhood/${addr}`) + assert.equal(u.search, '') + assert.equal(u.hash, '') + } +}) diff --git a/src/lib/dexscreener.ts b/src/lib/dexscreener.ts new file mode 100644 index 0000000..d5a23c2 --- /dev/null +++ b/src/lib/dexscreener.ts @@ -0,0 +1,33 @@ +// Outbound links to a pair's DexScreener chart. +// +// This is the app's only navigation to a third-party origin whose path is built +// from data the app did not author — pool addresses arrive from the indexer API +// and from chain reads — so the URL is assembled defensively: +// +// · origin and chain slug are compile-time constants, so no field of any API +// response can move where the link points; +// · the one variable segment must be exactly a 20-byte hex address or the +// builder returns null and the caller renders no link at all. That rejects +// `javascript:`/`data:` payloads, protocol-relative `//evil.tld`, path +// traversal, and the `?` / `#` / `@` tricks that re-target a URL by +// appending to it; +// · the segment is percent-encoded anyway. A no-op for valid hex, but it +// keeps the guarantee if anyone ever loosens the pattern. +// +// Callers render the result as a real , never +// window.open() or a location assignment: the browser gets to apply its own +// scheme checks, the target page gets no Referer (these URLs sit beside a +// connected wallet) and no window.opener handle back into this tab. + +/** dexscreener's chain slug for Robinhood Chain — the same one every API path + * in lib/poolstats.ts and lib/uniBrowse.ts already uses */ +const DS_PAIR_BASE = 'https://dexscreener.com/robinhood/' + +/** a 20-byte hex address and nothing else: no whitespace, prefix or tail */ +const ADDRESS = /^0x[0-9a-fA-F]{40}$/ + +/** DexScreener pair page for `pool`, or null when `pool` is not an address */ +export function dsPairUrl(pool: string): string | null { + if (!ADDRESS.test(pool)) return null + return DS_PAIR_BASE + encodeURIComponent(pool) +} diff --git a/src/lib/directSwap.test.ts b/src/lib/directSwap.test.ts new file mode 100644 index 0000000..92fe91d --- /dev/null +++ b/src/lib/directSwap.test.ts @@ -0,0 +1,499 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { decodeFunctionData, getAddress, zeroAddress, type Address, type PublicClient } from 'viem' +import { clSwapRouterAbi, uniSwapRouterAbi } from '../abi' +import { ADDR, NATIVE, UNI } from '../config/addresses' +import type { Pool } from '../types' +import { + buildDirectTransaction, + grossMinimumForNet, + netAfterFee, + quoteDirectCandidates, + type DirectRoute, +} from './directSwap' + +const tokenIn = getAddress('0x0000000000000000000000000000000000000011') +const tokenOut = getAddress('0x0000000000000000000000000000000000000022') +const recipient = getAddress('0x0000000000000000000000000000000000000033') +const feeReceiver = getAddress('0x0000000000000000000000000000000000000044') +const fee = { bps: 9, receiver: feeReceiver } + +function expectAddress(actual: Address, expected: Address): void { + assert.equal(actual.toLowerCase(), expected.toLowerCase()) +} + +test('gross minimum is the least amount whose net covers the requested minimum', () => { + for (const net of [0n, 1n, 999n, 10_000n, 1_000_000_000_000_000_000n]) { + const gross = grossMinimumForNet(net, 9) + assert.ok(netAfterFee(gross, 9) >= net) + if (gross > 0n) assert.ok(netAfterFee(gross - 1n, 9) < net) + } +}) + +test('quotes direct Uniswap tiers and matching UP33 CL pools in one multicall, ranked net of fee', async () => { + const pools = [ + { + kind: 'cl', + protocol: 'up33', + token0: tokenIn, + token1: tokenOut, + tickSpacing: 200, + feePpm: 10_000, + }, + { kind: 'v2', protocol: 'up33', token0: tokenIn, token1: tokenOut }, + { + kind: 'cl', + protocol: 'up33', + token0: tokenIn, + token1: getAddress('0x0000000000000000000000000000000000000099'), + tickSpacing: 100, + feePpm: 500, + }, + // The registry is permissionless and metadata calls can fail independently: + // an unusable pool on this pair must not take the working routes down. + { + kind: 'cl', + protocol: 'up33', + token0: tokenIn, + token1: tokenOut, + tickSpacing: 50, + feePpm: 1_000_000, + }, + { + kind: 'cl', + protocol: 'up33', + token0: tokenIn, + token1: tokenOut, + tickSpacing: 0, + feePpm: 500, + }, + ] as Pool[] + let calls = 0 + const outputs: readonly (readonly [bigint, bigint])[] = [ + [19_000n, 200n], + [20_000n, 200n], + [21_000n, 220n], + ] + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls++ + if (calls === 1) { + assert.equal(contracts.length, 5) + return contracts.map((_, index) => ({ + status: 'success' as const, + result: + index < 2 + ? getAddress(`0x${String(index + 1).padStart(40, '0')}`) + : zeroAddress, + })) + } + assert.equal(contracts.length, 6) + expectAddress(contracts[0].address as Address, UNI.V2_ROUTER) + expectAddress(contracts[2].address as Address, UNI.V3_QUOTER) + expectAddress(contracts[4].address as Address, ADDR.CL_QUOTER) + assert.equal( + ((contracts[4].args as readonly [{ tickSpacing: number }])[0]).tickSpacing, + 200, + ) + return contracts.map((_, index) => { + const output = outputs[Math.floor(index / 2)][index % 2] + return { + status: 'success' as const, + result: index < 2 ? [index % 2 ? 100n : 10_000n, output] : [output, 0n, 0, 0n], + } + }) + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, pools, tokenIn, tokenOut, 10_000n, 9) + + assert.equal(calls, 2) + assert.equal(quotes.best?.route.protocol, 'up33') + assert.equal(quotes.best?.amountOut, 20_982n) + assert.equal(quotes.best?.impactBps, 455) + assert.equal(quotes.byProtocol.uniswap?.route.kind, 'v3') + assert.equal(quotes.byProtocol.up33, quotes.best) + assert.deepEqual(quotes.status, { uniswap: 'quoted', up33: 'quoted' }) + // The CL winner's Quoter response does not expose its per-step rounded fee, + // so the direct fallback cannot honestly reconstruct a fee-free baseline. + assert.equal(quotes.midOut, null) +}) + +test('builds a usable direct baseline when the probe winner is Uniswap V2', async () => { + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls += 1 + if (calls === 1) { + return contracts.map((_, index) => ({ + status: 'success' as const, + result: index === 0 ? getAddress('0x0000000000000000000000000000000000000055') : zeroAddress, + })) + } + return [ + { status: 'success' as const, result: [10_000n, 20_060n] }, + { status: 'success' as const, result: [100n, 200n] }, + ] + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, [], tokenIn, tokenOut, 10_000n, 9) + + assert.equal(quotes.best?.route.kind, 'v2') + assert.equal(quotes.midOut, 20_060n) +}) + +test('rejects a V2 baseline destroyed by small-probe rounding', async () => { + const reserve = 1_000_000_000_000n + const v2Out = (amountIn: bigint) => (amountIn * 997n * reserve) / (reserve * 1_000n + amountIn * 997n) + const fullOut = v2Out(300n) + const probeOut = v2Out(3n) + assert.deepEqual([fullOut, probeOut], [299n, 2n]) + + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls += 1 + if (calls === 1) { + return contracts.map((_, index) => ({ + status: 'success' as const, + result: index === 0 ? getAddress('0x0000000000000000000000000000000000000055') : zeroAddress, + })) + } + return [ + { status: 'success' as const, result: [300n, fullOut] }, + { status: 'success' as const, result: [3n, probeOut] }, + ] + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, [], tokenIn, tokenOut, 300n, 0) + + assert.equal(quotes.best?.amountOut, 299n) + assert.equal(quotes.midOut, null) +}) + +test('rejects a V2 baseline below another route full quote', async () => { + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls += 1 + if (calls === 1) { + return contracts.map((_, index) => ({ + status: 'success' as const, + result: index < 2 ? getAddress(`0x${String(index + 1).padStart(40, '0')}`) : zeroAddress, + })) + } + return [ + { status: 'success' as const, result: [10_000n, 9_900n] }, + { status: 'success' as const, result: [100n, 99n] }, + { status: 'success' as const, result: [9_930n, 0n, 0, 0n] }, + { status: 'success' as const, result: [98n, 0n, 0, 0n] }, + ] + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, [], tokenIn, tokenOut, 10_000n, 9) + + // The V2 probe implies 9_929, which is above the best net (9_922) but + // below its full gross (9_930): terminal fees must not hide the bad baseline. + assert.equal(quotes.best?.amountOut, 9_922n) + assert.equal(quotes.midOut, null) +}) + +test('distinguishes a failed protocol quote from an absent pool', async () => { + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls++ + if (calls === 1) { + return contracts.map((_, index) => ({ + status: 'success' as const, + result: index === 1 ? getAddress('0x0000000000000000000000000000000000000055') : zeroAddress, + })) + } + return contracts.map(() => ({ status: 'failure' as const })) + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, [], tokenIn, tokenOut, 10_000n, 9) + + assert.equal(quotes.best, null) + assert.deepEqual(quotes.status, { uniswap: 'failed', up33: 'absent' }) +}) + +test('does not quote a partially discovered Uniswap protocol', async () => { + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls++ + return contracts.map((_, index) => + index === 0 + ? { status: 'failure' as const } + : { status: 'success' as const, result: zeroAddress }, + ) + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, [], tokenIn, tokenOut, 10_000n, 9) + + assert.equal(calls, 1) + assert.equal(quotes.best, null) + assert.deepEqual(quotes.status, { uniswap: 'failed', up33: 'absent' }) +}) + +test('ranks a protocol by its executable tiers when another tier reverts', async () => { + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls++ + if (calls === 1) { + // discovery: the v2 pair and the first v3 tier both exist + return contracts.map((_, index) => ({ + status: 'success' as const, + result: index < 2 ? getAddress(`0x${String(index + 1).padStart(40, '0')}`) : zeroAddress, + })) + } + return contracts.map((_, index) => { + // the v3 tier is created but illiquid — its quote reverts; v2 fills fine + if (index >= 2) return { status: 'failure' as const } + return { status: 'success' as const, result: index === 0 ? [10_000n, 20_000n] : [100n, 200n] } + }) + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, [], tokenIn, tokenOut, 10_000n, 9) + + // a dead fee-tier pool no longer poisons the protocol: the surviving v2 tier + // still ranks and becomes the best executable quote + assert.equal(quotes.best?.route.kind, 'v2') + assert.equal(quotes.best?.amountOut, 19_982n) + assert.equal(quotes.byProtocol.uniswap?.route.kind, 'v2') + assert.deepEqual(quotes.status, { uniswap: 'quoted', up33: 'absent' }) +}) + +test('keeps a full Uniswap quote when impact probing or the UP33 registry is unavailable', async () => { + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls++ + if (calls === 1) { + return contracts.map((_, index) => ({ + status: 'success' as const, + result: index === 1 ? getAddress('0x0000000000000000000000000000000000000055') : zeroAddress, + })) + } + assert.equal(contracts.length, 2) + return [{ status: 'success' as const, result: [20_000n, 0n, 0, 0n] }, { status: 'failure' as const }] + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, null, tokenIn, tokenOut, 10_000n, 9) + + assert.equal(quotes.best?.amountOut, 19_982n) + assert.equal(quotes.best?.impactBps, null) + assert.deepEqual(quotes.status, { uniswap: 'quoted', up33: 'failed' }) +}) + +test('marks impact unavailable when the amount is too small for a distinct probe', async () => { + let calls = 0 + const client = { + multicall: async ({ contracts }: { contracts: readonly Record[] }) => { + calls++ + if (calls === 1) { + return contracts.map((_, index) => ({ + status: 'success' as const, + result: index === 1 ? getAddress('0x0000000000000000000000000000000000000055') : zeroAddress, + })) + } + assert.equal(contracts.length, 1) + return [{ status: 'success' as const, result: [80n, 0n, 0, 0n] }] + }, + } as unknown as PublicClient + + const quotes = await quoteDirectCandidates(client, [], tokenIn, tokenOut, 50n, 9) + + assert.equal(quotes.best?.amountOut, 80n) + assert.equal(quotes.best?.impactBps, null) + // no probe, so no baseline either — this is the one state where the UI has + // no cost to show and says so + assert.equal(quotes.midOut, null) + assert.deepEqual(quotes.status, { uniswap: 'quoted', up33: 'absent' }) +}) + +test('Uniswap V2 multicall fixes swap target, gross minimum and fee settlement', () => { + const minimumAmountOut = 10_000n + const grossMinimum = grossMinimumForNet(minimumAmountOut, fee.bps) + const transaction = buildDirectTransaction({ + tokenIn, + tokenOut, + amountIn: 20_000n, + minimumAmountOut, + recipient, + deadline: 123n, + route: { protocol: 'uniswap', kind: 'v2', feePpm: 3000 }, + fee, + }) + + expectAddress(transaction.to, UNI.V3_SWAP_ROUTER) + expectAddress(transaction.spender!, UNI.V3_SWAP_ROUTER) + expectAddress(transaction.inputToken!, tokenIn) + expectAddress(transaction.outputToken!, tokenOut) + assert.equal(transaction.value, 0n) + const outer = decodeFunctionData({ abi: uniSwapRouterAbi, data: transaction.data }) + assert.equal(outer.functionName, 'multicall') + assert.equal(outer.args[0], 123n) + const [swapData, settleData] = outer.args[1] + const swap = decodeFunctionData({ abi: uniSwapRouterAbi, data: swapData }) + assert.equal(swap.functionName, 'swapExactTokensForTokens') + assert.deepEqual(swap.args, [20_000n, grossMinimum, [tokenIn, tokenOut], getAddress(UNI.V3_SWAP_ROUTER)]) + const settle = decodeFunctionData({ abi: uniSwapRouterAbi, data: settleData }) + assert.equal(settle.functionName, 'sweepTokenWithFee') + assert.deepEqual(settle.args, [tokenOut, grossMinimum, recipient, 9n, feeReceiver]) +}) + +test('Uniswap V3 native output is unwrapped atomically after the fee', () => { + const grossMinimum = grossMinimumForNet(10_000n, fee.bps) + const transaction = buildDirectTransaction({ + tokenIn, + tokenOut: NATIVE, + amountIn: 20_000n, + minimumAmountOut: 10_000n, + recipient, + deadline: 123n, + route: { protocol: 'uniswap', kind: 'v3', feePpm: 500 }, + fee, + }) + + assert.equal(transaction.outputToken, null) + const outer = decodeFunctionData({ abi: uniSwapRouterAbi, data: transaction.data }) + assert.equal(outer.functionName, 'multicall') + const swap = decodeFunctionData({ abi: uniSwapRouterAbi, data: outer.args[1][0] }) + assert.equal(swap.functionName, 'exactInputSingle') + assert.deepEqual(swap.args[0], { + tokenIn, + tokenOut: ADDR.WETH, + fee: 500, + recipient: getAddress(UNI.V3_SWAP_ROUTER), + amountIn: 20_000n, + amountOutMinimum: grossMinimum, + sqrtPriceLimitX96: 0n, + }) + const settle = decodeFunctionData({ abi: uniSwapRouterAbi, data: outer.args[1][1] }) + assert.equal(settle.functionName, 'unwrapWETH9WithFee') + assert.deepEqual(settle.args, [grossMinimum, recipient, 9n, feeReceiver]) +}) + +test('UP33 CL native input uses WETH calldata, ETH value and no approval', () => { + const grossMinimum = grossMinimumForNet(10_000n, fee.bps) + const transaction = buildDirectTransaction({ + tokenIn: NATIVE, + tokenOut, + amountIn: 20_000n, + minimumAmountOut: 10_000n, + recipient, + deadline: 123n, + route: { protocol: 'up33', kind: 'cl', tickSpacing: 200, feePpm: 10_000 }, + fee, + }) + + expectAddress(transaction.to, ADDR.CL_SWAP_ROUTER) + assert.equal(transaction.spender, null) + assert.equal(transaction.inputToken, null) + assert.equal(transaction.value, 20_000n) + const outer = decodeFunctionData({ abi: clSwapRouterAbi, data: transaction.data }) + assert.equal(outer.functionName, 'multicall') + const swap = decodeFunctionData({ abi: clSwapRouterAbi, data: outer.args[0][0] }) + assert.equal(swap.functionName, 'exactInputSingle') + assert.deepEqual(swap.args[0], { + tokenIn: ADDR.WETH, + tokenOut, + tickSpacing: 200, + recipient: zeroAddress, + deadline: 123n, + amountIn: 20_000n, + amountOutMinimum: grossMinimum, + sqrtPriceLimitX96: 0n, + }) + const settle = decodeFunctionData({ abi: clSwapRouterAbi, data: outer.args[0][1] }) + assert.equal(settle.functionName, 'sweepTokenWithFee') + assert.deepEqual(settle.args, [tokenOut, grossMinimum, recipient, 9n, feeReceiver]) +}) + +test('zero-fee Uniswap swap settles through plain sweepToken, no fee args', () => { + const grossMinimum = grossMinimumForNet(10_000n, 0) + const transaction = buildDirectTransaction({ + tokenIn, + tokenOut, + amountIn: 20_000n, + minimumAmountOut: 10_000n, + recipient, + deadline: 123n, + route: { protocol: 'uniswap', kind: 'v2', feePpm: 3000 }, + fee: { bps: 0, receiver: feeReceiver }, + }) + + const outer = decodeFunctionData({ abi: uniSwapRouterAbi, data: transaction.data }) + assert.equal(outer.functionName, 'multicall') + const settle = decodeFunctionData({ abi: uniSwapRouterAbi, data: outer.args[1][1] }) + assert.equal(settle.functionName, 'sweepToken') + assert.deepEqual(settle.args, [tokenOut, grossMinimum, recipient]) +}) + +test('zero-fee CL native output unwraps through plain unwrapWETH9, no fee args', () => { + const grossMinimum = grossMinimumForNet(10_000n, 0) + const transaction = buildDirectTransaction({ + tokenIn, + tokenOut: NATIVE, + amountIn: 20_000n, + minimumAmountOut: 10_000n, + recipient, + deadline: 123n, + route: { protocol: 'up33', kind: 'cl', tickSpacing: 200, feePpm: 10_000 }, + fee: { bps: 0, receiver: feeReceiver }, + }) + + const outer = decodeFunctionData({ abi: clSwapRouterAbi, data: transaction.data }) + assert.equal(outer.functionName, 'multicall') + const settle = decodeFunctionData({ abi: clSwapRouterAbi, data: outer.args[0][1] }) + assert.equal(settle.functionName, 'unwrapWETH9') + assert.deepEqual(settle.args, [grossMinimum, recipient]) +}) + +test('rejects routes that do not belong to a supported direct protocol shape', () => { + const base = { + tokenIn, + tokenOut, + amountIn: 20_000n, + minimumAmountOut: 10_000n, + recipient, + deadline: 123n, + fee, + } + assert.throws( + () => + buildDirectTransaction({ + ...base, + route: { protocol: 'up33', kind: 'v2', feePpm: 3000 } as unknown as DirectRoute, + }), + /Unsupported direct route/, + ) + // a 100% CL fee makes the fee-free baseline divide by zero — up33 reads its + // fee live off the pool, so this is the only route fee that is not a constant + assert.throws( + () => + buildDirectTransaction({ + ...base, + route: { protocol: 'up33', kind: 'cl', tickSpacing: 200, feePpm: 1_000_000 }, + }), + /Unsupported direct route/, + ) + assert.throws( + () => + buildDirectTransaction({ + ...base, + route: { protocol: 'uniswap', kind: 'v3', feePpm: 2500 } as unknown as DirectRoute, + }), + /Unsupported direct route/, + ) +}) diff --git a/src/lib/directSwap.ts b/src/lib/directSwap.ts new file mode 100644 index 0000000..c9c033b --- /dev/null +++ b/src/lib/directSwap.ts @@ -0,0 +1,543 @@ +import { + encodeFunctionData, + getAddress, + zeroAddress, + type Address, + type Hex, + type PublicClient, +} from 'viem' +import { + clSwapRouterAbi, + quoterAbi, + uniSwapRouterAbi, + uniV2FactoryAbi, + uniV2RouterAbi, + uniV3FactoryAbi, + uniV3QuoterAbi, +} from '../abi' +import { ADDR, NATIVE, UNI } from '../config/addresses' +import type { Pool } from '../types' + +export type DirectProtocol = 'uniswap' | 'up33' +type UniV2Route = { protocol: 'uniswap'; kind: 'v2'; feePpm: 3000 } +type UniV3Route = { + protocol: 'uniswap' + kind: 'v3' + feePpm: 100 | 500 | 3000 | 10000 +} +type Up33ClRoute = { + protocol: 'up33' + kind: 'cl' + tickSpacing: number + feePpm: number +} +export type DirectRoute = UniV2Route | UniV3Route | Up33ClRoute + +export type DirectCandidate = { + route: DirectRoute + amountOut: bigint + impactBps: number | null +} + +export type DirectQuotes = { + best: DirectCandidate | null + byProtocol: Record + status: Record + /** probe-derived fee-free V2 price when resolution permits — the fallback cost denominator */ + midOut: bigint | null +} + +type DirectTransaction = { + to: Address + data: Hex + value: bigint + spender: Address | null + inputToken: Address | null + outputToken: Address | null +} + +const UNI_V3_FEES = [100, 500, 3000, 10000] as const +const UNI_ROUTES: readonly DirectRoute[] = [ + { protocol: 'uniswap', kind: 'v2', feePpm: 3000 }, + ...UNI_V3_FEES.map((feePpm) => ({ protocol: 'uniswap' as const, kind: 'v3' as const, feePpm })), +] + +export function isNative(address?: Address): boolean { + return !!address && address.toLowerCase() === NATIVE.toLowerCase() +} + +export function erc20Of(address: Address): Address { + return isNative(address) ? ADDR.WETH : getAddress(address) +} + +function assertFeeBps(feeBps: number): void { + if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps >= 10_000) { + throw new Error(`Invalid fee bps: ${feeBps}`) + } +} + +export function netAfterFee(grossAmount: bigint, feeBps: number): bigint { + if (grossAmount < 0n) throw new Error('Gross amount cannot be negative') + assertFeeBps(feeBps) + return grossAmount - (grossAmount * BigInt(feeBps)) / 10_000n +} + +export function grossMinimumForNet(netAmount: bigint, feeBps: number): bigint { + if (netAmount < 0n) throw new Error('Net amount cannot be negative') + assertFeeBps(feeBps) + if (netAmount === 0n) return 0n + return ((netAmount - 1n) * 10_000n) / BigInt(10_000 - feeBps) + 1n +} + +function assertRoute(route: DirectRoute): void { + if (route.protocol === 'uniswap' && route.kind === 'v2' && route.feePpm === 3000) return + if ( + route.protocol === 'uniswap' && + route.kind === 'v3' && + UNI_V3_FEES.includes(route.feePpm) + ) { + return + } + if ( + route.protocol === 'up33' && + route.kind === 'cl' && + Number.isInteger(route.tickSpacing) && + route.tickSpacing !== 0 && + Number.isInteger(route.feePpm) && + route.feePpm > 0 && + // A route cannot execute at a 100% LP fee. Discovery filters these out; + // this also covers routes handed in directly. + route.feePpm < 1_000_000 + ) { + return + } + throw new Error('Unsupported direct route') +} + +function matchesPair(pool: Pool, tokenIn: Address, tokenOut: Address): boolean { + const input = erc20Of(tokenIn).toLowerCase() + const output = erc20Of(tokenOut).toLowerCase() + const token0 = pool.token0.toLowerCase() + const token1 = pool.token1.toLowerCase() + return (token0 === input && token1 === output) || (token0 === output && token1 === input) +} + +function up33Routes(pools: readonly Pool[], tokenIn: Address, tokenOut: Address): DirectRoute[] { + const seen = new Set() + const routes: Up33ClRoute[] = [] + + for (const pool of pools) { + if ( + pool.kind !== 'cl' || + pool.protocol !== 'up33' || + !matchesPair(pool, tokenIn, tokenOut) || + !Number.isInteger(pool.tickSpacing) || + pool.tickSpacing === 0 || + // Drop unusable permissionless pools before one can poison the batch. + pool.feePpm <= 0 || + pool.feePpm >= 1_000_000 + ) { + continue + } + const key = `${pool.tickSpacing}:${pool.feePpm}` + if (seen.has(key)) continue + seen.add(key) + routes.push({ + protocol: 'up33', + kind: 'cl', + tickSpacing: pool.tickSpacing, + feePpm: pool.feePpm, + }) + } + return routes +} + +type RouteDiscovery = { + routes: DirectRoute[] + failedProtocols: Set +} + +async function directRoutes( + client: PublicClient, + pools: readonly Pool[] | null, + tokenIn: Address, + tokenOut: Address, +): Promise { + const input = erc20Of(tokenIn) + const output = erc20Of(tokenOut) + const contracts = [ + { + abi: uniV2FactoryAbi, + address: UNI.V2_FACTORY, + functionName: 'getPair', + args: [input, output], + }, + ...UNI_V3_FEES.map((fee) => ({ + abi: uniV3FactoryAbi, + address: UNI.V3_FACTORY, + functionName: 'getPool' as const, + args: [input, output, fee], + })), + ] + const discovery = (await client.multicall({ + contracts: contracts as never, + })) as MulticallResult[] + const failedProtocols = new Set() + const uniswapDiscoveryFailed = discovery.some(({ status }) => status === 'failure') + if (uniswapDiscoveryFailed) failedProtocols.add('uniswap') + if (pools === null) failedProtocols.add('up33') + + const uniswap = uniswapDiscoveryFailed + ? [] + : UNI_ROUTES.filter((_, index) => { + const address = discovery[index].result as Address + return address.toLowerCase() !== zeroAddress + }) + const up33 = pools === null ? [] : up33Routes(pools, tokenIn, tokenOut) + return { routes: [...uniswap, ...up33], failedProtocols } +} + +function quoteContract( + route: DirectRoute, + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, +) { + const input = erc20Of(tokenIn) + const output = erc20Of(tokenOut) + if (route.protocol === 'uniswap' && route.kind === 'v2') { + return { + abi: uniV2RouterAbi, + address: UNI.V2_ROUTER, + functionName: 'getAmountsOut', + args: [amountIn, [input, output]], + } as const + } + if (route.protocol === 'uniswap') { + return { + abi: uniV3QuoterAbi, + address: UNI.V3_QUOTER, + functionName: 'quoteExactInputSingle', + args: [{ tokenIn: input, tokenOut: output, amountIn, fee: route.feePpm, sqrtPriceLimitX96: 0n }], + } as const + } + return { + abi: quoterAbi, + address: ADDR.CL_QUOTER, + functionName: 'quoteExactInputSingle', + args: [ + { + tokenIn: input, + tokenOut: output, + amountIn, + tickSpacing: route.tickSpacing, + sqrtPriceLimitX96: 0n, + }, + ], + } as const +} + +type MulticallResult = { status: 'success' | 'failure'; result?: unknown } +type DirectQuoteStatus = DirectQuotes['status'][DirectProtocol] + +function quoteStatus( + protocol: DirectProtocol, + candidates: readonly DirectCandidate[], + failedProtocols: ReadonlySet, +): DirectQuoteStatus { + // an executable tier wins: a protocol that produced at least one quote is + // 'quoted' even when another of its fee tiers reverted. A created-but-illiquid + // pool (whose quote reverts) must not poison the protocol's working routes — + // it can't fill the swap anyway, so the best EXECUTABLE quote is the max over + // the tiers that did respond. + if (candidates.some(({ route }) => route.protocol === protocol)) return 'quoted' + if (failedProtocols.has(protocol)) return 'failed' + return 'absent' +} + +function quotedAmount(route: DirectRoute, result: MulticallResult): bigint | null { + if (result.status !== 'success') return null + if (route.protocol === 'uniswap' && route.kind === 'v2') { + const amounts = result.result as readonly bigint[] + return amounts.at(-1) ?? null + } + const [amountOut] = result.result as readonly [bigint, bigint, number, bigint] + return amountOut +} + +export function impactBps(amountIn: bigint, amountOut: bigint, probeIn: bigint, probeOut: bigint): number { + const probeRate = probeOut * amountIn + const fullRate = amountOut * probeIn + if (fullRate >= probeRate) return 0 + return Number(((probeRate - fullRate) * 10_000n + probeRate - 1n) / probeRate) +} + +/** `amountIn` at a V2 probe's fee-free price. Reject probe rounding that cannot + * place the baseline at or above every full-size gross output. + * V3/CL Quoters do not expose enough fee information to reconstruct one. */ +function midAmountOut( + probe: { out: bigint; feePpm: number }, + probeIn: bigint, + amountIn: bigint, + maxGrossAmountOut: bigint, +): bigint | null { + const midOut = (probe.out * 1_000_000n * amountIn) / (BigInt(1_000_000 - probe.feePpm) * probeIn) + return midOut >= maxGrossAmountOut ? midOut : null +} + +async function quoteRoutes( + client: PublicClient, + routes: readonly DirectRoute[], + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, + feeBps: number, +): Promise<{ candidates: DirectCandidate[]; midOut: bigint | null; failedProtocols: Set }> { + if (amountIn <= 0n) throw new Error('Swap amount must be positive') + if (erc20Of(tokenIn).toLowerCase() === erc20Of(tokenOut).toLowerCase()) { + throw new Error('Swap tokens must differ') + } + assertFeeBps(feeBps) + routes.forEach(assertRoute) + if (routes.length === 0) return { candidates: [], midOut: null, failedProtocols: new Set() } + + const probeIn = amountIn / 100n + const hasProbe = probeIn > 0n + const contracts = routes.flatMap((route) => [ + quoteContract(route, tokenIn, tokenOut, amountIn), + ...(hasProbe ? [quoteContract(route, tokenIn, tokenOut, probeIn)] : []), + ]) + const results = (await client.multicall({ contracts: contracts as never })) as MulticallResult[] + const candidates: DirectCandidate[] = [] + const failedProtocols = new Set() + const stride = hasProbe ? 2 : 1 + const probes: { out: bigint; route: DirectRoute }[] = [] + let maxGrossAmountOut = 0n + + routes.forEach((route, index) => { + const grossAmountOut = quotedAmount(route, results[index * stride]) + if (!grossAmountOut) { + failedProtocols.add(route.protocol) + return + } + if (grossAmountOut > maxGrossAmountOut) maxGrossAmountOut = grossAmountOut + const probeAmountOut = hasProbe ? quotedAmount(route, results[index * stride + 1]) : null + if (probeAmountOut) probes.push({ out: probeAmountOut, route }) + candidates.push({ + route, + amountOut: netAfterFee(grossAmountOut, feeBps), + impactBps: probeAmountOut ? impactBps(amountIn, grossAmountOut, probeIn, probeAmountOut) : null, + }) + }) + + candidates.sort((a, b) => { + if (a.amountOut !== b.amountOut) return a.amountOut > b.amountOut ? -1 : 1 + if (a.impactBps === null) return b.impactBps === null ? 0 : 1 + if (b.impactBps === null) return -1 + return a.impactBps - b.impactBps + }) + // Every route probed the same probeIn, so the outputs rank directly. Only V2 + // exposes enough information to undo its fee exactly on the direct path. + const winner = probes.length ? probes.reduce((a, b) => (b.out > a.out ? b : a)) : null + return { + candidates, + midOut: + winner?.route.kind === 'v2' + ? midAmountOut({ out: winner.out, feePpm: winner.route.feePpm }, probeIn, amountIn, maxGrossAmountOut) + : null, + failedProtocols, + } +} + +export async function quoteDirectCandidates( + client: PublicClient, + pools: readonly Pool[] | null, + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, + feeBps: number, +): Promise { + const discovery = await directRoutes(client, pools, tokenIn, tokenOut) + const quoted = await quoteRoutes( + client, + discovery.routes, + tokenIn, + tokenOut, + amountIn, + feeBps, + ) + const failedProtocols = new Set([ + ...discovery.failedProtocols, + ...quoted.failedProtocols, + ]) + const status = { + uniswap: quoteStatus('uniswap', quoted.candidates, failedProtocols), + up33: quoteStatus('up33', quoted.candidates, failedProtocols), + } + const candidates = quoted.candidates.filter(({ route }) => status[route.protocol] === 'quoted') + return { + best: candidates[0] ?? null, + byProtocol: { + uniswap: candidates.find(({ route }) => route.protocol === 'uniswap') ?? null, + up33: candidates.find(({ route }) => route.protocol === 'up33') ?? null, + }, + status, + midOut: quoted.midOut, + } +} + +export async function quoteDirectRoute( + client: PublicClient, + route: DirectRoute, + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, + feeBps: number, +): Promise { + const { candidates: [candidate] } = await quoteRoutes(client, [route], tokenIn, tokenOut, amountIn, feeBps) + if (!candidate) throw new Error(`${directRouteLabel(route)} route is no longer quotable`) + return candidate +} + +function feeSettlement( + abi: typeof uniSwapRouterAbi | typeof clSwapRouterAbi, + tokenOut: Address, + grossMinimum: bigint, + recipient: Address, + fee: { bps: number; receiver: Address }, +): Hex { + // the *WithFee periphery functions require feeBips in [1, 100] — a zero + // fee must settle through the plain sweep/unwrap variants + if (fee.bps === 0) { + return isNative(tokenOut) + ? encodeFunctionData({ abi, functionName: 'unwrapWETH9', args: [grossMinimum, recipient] }) + : encodeFunctionData({ + abi, + functionName: 'sweepToken', + args: [erc20Of(tokenOut), grossMinimum, recipient], + }) + } + return isNative(tokenOut) + ? encodeFunctionData({ + abi, + functionName: 'unwrapWETH9WithFee', + args: [grossMinimum, recipient, BigInt(fee.bps), fee.receiver], + }) + : encodeFunctionData({ + abi, + functionName: 'sweepTokenWithFee', + args: [erc20Of(tokenOut), grossMinimum, recipient, BigInt(fee.bps), fee.receiver], + }) +} + +export function buildDirectTransaction(args: { + tokenIn: Address + tokenOut: Address + amountIn: bigint + minimumAmountOut: bigint + recipient: Address + deadline: bigint + route: DirectRoute + fee: { bps: number; receiver: Address } +}): DirectTransaction { + assertRoute(args.route) + if (args.amountIn <= 0n || args.minimumAmountOut <= 0n) throw new Error('Swap amounts must be positive') + // 0 is valid: a zero fee settles through the plain sweep/unwrap variants + // (see feeSettlement). 1..100 use the *WithFee periphery functions. + if (!Number.isInteger(args.fee.bps) || args.fee.bps < 0 || args.fee.bps > 100) { + throw new Error(`Invalid router fee bps: ${args.fee.bps}`) + } + if (args.deadline <= 0n) throw new Error('Swap deadline must be positive') + if (args.recipient.toLowerCase() === zeroAddress || args.fee.receiver.toLowerCase() === zeroAddress) { + throw new Error('Swap and fee recipients must be nonzero') + } + + const tokenIn = erc20Of(args.tokenIn) + const tokenOut = erc20Of(args.tokenOut) + if (tokenIn.toLowerCase() === tokenOut.toLowerCase()) throw new Error('Swap tokens must differ') + const grossMinimum = grossMinimumForNet(args.minimumAmountOut, args.fee.bps) + const nativeInput = isNative(args.tokenIn) + + if (args.route.protocol === 'uniswap') { + const swap = + args.route.kind === 'v2' + ? encodeFunctionData({ + abi: uniSwapRouterAbi, + functionName: 'swapExactTokensForTokens', + args: [args.amountIn, grossMinimum, [tokenIn, tokenOut], UNI.V3_SWAP_ROUTER], + }) + : encodeFunctionData({ + abi: uniSwapRouterAbi, + functionName: 'exactInputSingle', + args: [ + { + tokenIn, + tokenOut, + fee: args.route.feePpm, + recipient: UNI.V3_SWAP_ROUTER, + amountIn: args.amountIn, + amountOutMinimum: grossMinimum, + sqrtPriceLimitX96: 0n, + }, + ], + }) + const settle = feeSettlement( + uniSwapRouterAbi, + args.tokenOut, + grossMinimum, + args.recipient, + args.fee, + ) + return { + to: UNI.V3_SWAP_ROUTER, + data: encodeFunctionData({ + abi: uniSwapRouterAbi, + functionName: 'multicall', + args: [args.deadline, [swap, settle]], + }), + value: nativeInput ? args.amountIn : 0n, + spender: nativeInput ? null : UNI.V3_SWAP_ROUTER, + inputToken: nativeInput ? null : tokenIn, + outputToken: isNative(args.tokenOut) ? null : tokenOut, + } + } + + const swap = encodeFunctionData({ + abi: clSwapRouterAbi, + functionName: 'exactInputSingle', + args: [ + { + tokenIn, + tokenOut, + tickSpacing: args.route.tickSpacing, + recipient: zeroAddress, + deadline: args.deadline, + amountIn: args.amountIn, + amountOutMinimum: grossMinimum, + sqrtPriceLimitX96: 0n, + }, + ], + }) + const settle = feeSettlement( + clSwapRouterAbi, + args.tokenOut, + grossMinimum, + args.recipient, + args.fee, + ) + return { + to: ADDR.CL_SWAP_ROUTER, + data: encodeFunctionData({ abi: clSwapRouterAbi, functionName: 'multicall', args: [[swap, settle]] }), + value: nativeInput ? args.amountIn : 0n, + spender: nativeInput ? null : ADDR.CL_SWAP_ROUTER, + inputToken: nativeInput ? null : tokenIn, + outputToken: isNative(args.tokenOut) ? null : tokenOut, + } +} + +export function directRouteLabel(route: DirectRoute): string { + assertRoute(route) + const fee = `${route.feePpm / 10_000}%` + if (route.protocol === 'up33') return `UP33 CL ${fee}` + return route.kind === 'v2' ? 'Uniswap V2' : `Uniswap V3 ${fee}` +} diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 0000000..da3a9c2 --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { fmtCompact, fmtNum, fmtUsd, sanitizeAmountInput } from './format' + +test('amount input: fraction clamps to the token decimals, never silently zero', () => { + // 6-decimal USDG: the 7th fraction digit is untypeable, not a hidden zero + assert.equal(sanitizeAmountInput('0.0000001', 6), '0.000000') + assert.equal(sanitizeAmountInput('0.0000015', 6), '0.000001') + assert.equal(sanitizeAmountInput('0.00005', 18), '0.00005') // within precision → untouched + assert.equal(sanitizeAmountInput('1.5', 0), '1') +}) + +test('amount input: scientific-notation pastes expand exactly (string math)', () => { + assert.equal(sanitizeAmountInput('5e-5', 18), '0.00005') + assert.equal(sanitizeAmountInput('3E-7', 18), '0.0000003') + assert.equal(sanitizeAmountInput('1.2e3', 18), '1200') + assert.equal(sanitizeAmountInput('1.2E+3', 6), '1200') + assert.equal(sanitizeAmountInput('2.5e-7', 6), '0.000000') // expanded, then clamped + assert.equal(sanitizeAmountInput('1e31', 18), null) // absurd exponents rejected + assert.equal(sanitizeAmountInput('e5', 18), null) // no mantissa = not an amount +}) + +test('amount input: normalization and rejection', () => { + assert.equal(sanitizeAmountInput('', 18), '') + assert.equal(sanitizeAmountInput('0,05', 18), '0.05') // decimal comma + assert.equal(sanitizeAmountInput(' 0.05 ', 18), '0.05') + assert.equal(sanitizeAmountInput('.05', 18), '.05') // leading dot parses fine downstream + assert.equal(sanitizeAmountInput('abc', 18), null) + assert.equal(sanitizeAmountInput('1.2.3', 18), null) + assert.equal(sanitizeAmountInput('-5', 18), null) +}) + +test('amount input: Chinese-IME fullwidth characters normalize instead of vanishing', () => { + // rejecting just the fullwidth dot turned "0。05" into "005" — a 100× error + assert.equal(sanitizeAmountInput('0。05', 6), '0.05') + assert.equal(sanitizeAmountInput('0.05', 6), '0.05') + assert.equal(sanitizeAmountInput('0.05', 18), '0.05') + assert.equal(sanitizeAmountInput('0,05', 18), '0.05') // fullwidth comma = decimal comma + assert.equal(sanitizeAmountInput('0。00000001', 6), '0.000000') // normalize, then clamp +}) + +test('fmtNum compresses many-zero decimals to subscript notation', () => { + assert.equal(fmtNum(0.000072051), '0.0₄72051') + assert.equal(fmtNum(0.00003), '0.0₄3') +}) + +test('compact table figures stay short enough for a phone column', () => { + assert.equal(fmtCompact(4_049), '4K') + assert.equal(fmtCompact(2_844_349), '2.8M') + // Intl's compact notation stops at T and then degrades into a grouped number: + // 2.99e23 used to render "298,973,758,851.7T" — 18 characters, nowrap, which + // widened the pool table past the viewport and forced sideways scrolling + assert.equal(fmtCompact(2.99e23), '3.0e+23') + for (const x of [0, 1, 999, 1e6, 1e12, 1e15, 2.99e23, 2.95e49, -1e30, 1e-9]) + assert.ok(fmtCompact(x).length <= 8, `fmtCompact(${x}) = "${fmtCompact(x)}" is too wide`) +}) + +test('fmtUsd is exact for real money and bounded for impossible money', () => { + assert.equal(fmtUsd(4_049), '$4,049') + assert.equal(fmtUsd(12.345), '$12.35') + assert.equal(fmtUsd(0.004), '<$0.01') + // third-party figures (dexscreener) are not sanity-checked upstream: past a + // trillion the number is wrong, and rendering it exactly costs 30 columns + assert.equal(fmtUsd(2.99e23), '$3.0e+23') + assert.ok(fmtUsd(298_973_758_851_651_660_000_000).length <= 9) +}) diff --git a/src/lib/format.ts b/src/lib/format.ts index cc6dda3..9cc6042 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,6 +1,14 @@ import { formatUnits } from 'viem' -/** significant-digit number formatting with thousands separators */ +const SUB_DIGITS = '₀₁₂₃₄₅₆₇₈₉' +const subscriptNum = (n: number): string => + String(n) + .split('') + .map((d) => SUB_DIGITS[Number(d)]) + .join('') + +/** significant-digit number formatting with thousands separators. + many-zero decimals compress to the common web3 notation: 0.000072051 → 0.0₄72051 */ export function fmtNum(x: number, sig = 5): string { if (!Number.isFinite(x)) return '—' if (x === 0) return '0' @@ -19,6 +27,8 @@ export function fmtNum(x: number, sig = 5): string { s = a.toFixed(Math.min(exp + sig, 18)) } s = s.replace(/\.?0+$/, '') + const zeros = s.match(/^0\.(0{4,})(?=[1-9])/) + if (zeros) s = `0.0${subscriptNum(zeros[1].length)}${s.slice(2 + zeros[1].length)}` } return (neg ? '-' : '') + s } @@ -27,10 +37,55 @@ 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 */ +/** exact decimal-point shift for scientific-notation pastes — string math only, + * a float round-trip would corrupt exactly the tiny amounts this serves */ +function shiftDecimal(int: string, frac: string, exp: number): string | null { + if (!Number.isFinite(exp) || Math.abs(exp) > 30) return null + const digits = int + frac + const point = int.length + exp + if (point <= 0) return `0.${'0'.repeat(-point)}${digits}` + if (point >= digits.length) return digits + '0'.repeat(point - digits.length) + return `${digits.slice(0, point)}.${digits.slice(point)}` +} + +/** normalize user-typed token amounts (the web3 small-amount conventions): + * whitespace stripped, decimal comma → dot, pasted scientific notation + * ("5e-5") expanded exactly, and the fraction CLAMPED to the token's decimals + * — text the token cannot represent must not sit in the box silently quoting + * zero. Returns the normalized string, or null when the text is not an amount + * (caller keeps the previous value, the controlled input swallows the key). */ +export function sanitizeAmountInput(raw: string, decimals: number): string | null { + let s = raw + .replace(/\s+/g, '') + // Chinese-IME width normalization: with an IME active the digit/dot keys + // emit fullwidth 0-9/。/./,— rejecting just the dot turned a typed + // "0。05" into "005", a 100× amount error, so map instead of reject + .replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xfee0)) + .replace(/[。.]/g, '.') + .replace(',', ',') + .replace(',', '.') + if (s === '') return '' + const sci = s.match(/^(\d*)\.?(\d*)[eE]([+-]?\d+)$/) + if (sci && (sci[1] || sci[2])) { + const shifted = shiftDecimal(sci[1], sci[2], Number(sci[3])) + if (shifted === null) return null + s = shifted + } + if (!/^\d*\.?\d*$/.test(s)) return null + const [int, frac = ''] = s.split('.') + if (frac.length > decimals) return decimals === 0 ? int : `${int}.${frac.slice(0, decimals)}` + return s +} + +/** compact amount for dense table cells: 24.9M, 338.4K, 12.4, 0.0421. + * Bounded by construction — ~8 characters whatever the input. Intl's compact + * notation stops at T and then silently degrades into a grouped number + * ("298,973,758,851.7T", 18 chars), which is the one thing a fixed-width cell + * cannot absorb: it widened the pool table past the viewport. */ export function fmtCompact(x: number): string { if (!Number.isFinite(x)) return '—' if (x !== 0 && Math.abs(x) < 1) return fmtNum(x, 3) + if (Math.abs(x) >= 1e15) return x.toExponential(1) return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(x) } @@ -44,6 +99,10 @@ export function fmtUsd(x: number | string | undefined): string { // 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' + // exact dollars are this function's job, but the inputs include third-party + // figures (dexscreener) we don't get to sanity-check: past a trillion the + // number is wrong, and rendering it exactly costs 30 characters of column + if (Math.abs(n) >= 1e12) return '$' + fmtCompact(n) return '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 1000 ? 0 : 2 }) } @@ -56,18 +115,6 @@ 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 diff --git a/src/lib/kyber.ts b/src/lib/kyber.ts index 5d0d3cf..445a676 100644 --- a/src/lib/kyber.ts +++ b/src/lib/kyber.ts @@ -1,134 +1,27 @@ -import type { Address, Hex } from 'viem' +import type { Address } 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( +/** Fee-free Kyber USD valuation. This module intentionally exposes no transaction builder. */ +export async function kyberUsdValue( tokenIn: Address, tokenOut: Address, amountIn: bigint, - opts?: { signal?: AbortSignal; applyFee?: boolean }, -): Promise { - // 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 -} + signal?: AbortSignal, +): Promise { + const url = new URL(`${api()}/routes`, location.origin) + url.searchParams.set('tokenIn', tokenIn) + url.searchParams.set('tokenOut', tokenOut) + url.searchParams.set('amountIn', amountIn.toString()) + url.searchParams.set('gasInclude', 'false') -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 { - 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}`) + const response = await fetch(url, { headers: HEADERS, signal }) + const payload = await response.json() + const value = Number(payload?.data?.routeSummary?.amountOutUsd) + if (!response.ok || payload?.code !== 0 || !Number.isFinite(value) || value <= 0) { + throw new Error(`Kyber valuation failed: ${payload?.message ?? response.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 { - 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(' · ') + return value } diff --git a/src/lib/kyberExec.ts b/src/lib/kyberExec.ts deleted file mode 100644 index eaed4a0..0000000 --- a/src/lib/kyberExec.ts +++ /dev/null @@ -1,48 +0,0 @@ -// 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 { - 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 } -} diff --git a/src/lib/limit.ts b/src/lib/limit.ts index e599d68..e9b9bb1 100644 --- a/src/lib/limit.ts +++ b/src/lib/limit.ts @@ -5,7 +5,7 @@ // and nothing executes automatically — the owner withdraws to lock in. // // Closed forms for liquidity L over sqrt bounds [A,B] (verified against -// CLPool/SqrtPriceMath): +// CLPool/SqrtPriceMath, see docs/up33-contract-map.md §6.6): // 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 diff --git a/src/lib/news.test.ts b/src/lib/news.test.ts new file mode 100644 index 0000000..08977ba --- /dev/null +++ b/src/lib/news.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { CHANGELOG, type NewsEntry } from '../content/changelog' +import { FRESH_MS, entryTime, freshCount, isFresh } from './news' + +const NOW = Date.parse('2026-07-21T09:00:00Z') +const at = (date: string): NewsEntry => ({ + id: date, + date, + tag: 'new', + title: { en: 'x', zh: 'x' }, + items: [], +}) + +test('an entry stays new for two days, then goes quiet on its own', () => { + assert.equal(isFresh(at('2026-07-21'), NOW), true) + assert.equal(isFresh(at('2026-07-20'), NOW), true) + assert.equal(isFresh(at('2026-07-19'), NOW), false) // 2d 9h old — past the window + const born = entryTime('2026-07-19') + assert.equal(isFresh(at('2026-07-19'), born + FRESH_MS - 1), true) // strictly younger counts + assert.equal(isFresh(at('2026-07-19'), born + FRESH_MS), false) +}) + +test('a future or malformed date never breaks the dot', () => { + assert.equal(isFresh(at('2026-12-01'), NOW), true) // written ahead of the ship date + assert.ok(Number.isNaN(entryTime('not-a-date'))) + assert.equal(isFresh(at('not-a-date'), NOW), false) // no dot, no crash +}) + +test('freshCount counts only what is inside the window', () => { + assert.equal(freshCount(NOW, [at('2026-07-21'), at('2026-07-20'), at('2026-07-01')]), 2) +}) + +test('the changelog holds its invariants', () => { + assert.ok(CHANGELOG.length > 0) + const ids = new Set() + let prev = Infinity + for (const e of CHANGELOG) { + assert.match(e.date, /^\d{4}-\d{2}-\d{2}$/, `${e.id}: date must be ISO YYYY-MM-DD`) + const ts = entryTime(e.date) + assert.ok(!Number.isNaN(ts), `${e.id}: unparseable date`) + assert.ok(ts <= prev, `${e.id}: entries must be listed newest first`) + prev = ts + assert.ok(!ids.has(e.id), `${e.id}: duplicate id`) + ids.add(e.id) + for (const s of [e.title, ...e.items]) { + assert.ok(s.en.trim().length > 0, `${e.id}: missing en text`) + assert.ok(s.zh.trim().length > 0, `${e.id}: missing zh text`) + } + } +}) diff --git a/src/lib/news.ts b/src/lib/news.ts new file mode 100644 index 0000000..a85c532 --- /dev/null +++ b/src/lib/news.ts @@ -0,0 +1,25 @@ +// WHAT'S NEW freshness. The header dot is a pure function of the clock and the +// catalog — there is no "seen" state and nothing in localStorage: an entry +// lights the dot while it is younger than FRESH_MS and goes quiet on its own. +import { CHANGELOG, type NewsEntry } from '../content/changelog' + +/** how long a shipped entry stays new (user's rule: two days) */ +export const FRESH_MS = 2 * 24 * 60 * 60 * 1000 + +/** epoch ms for an entry's ship date — NaN if the catalog date is malformed */ +export function entryTime(date: string): number { + return Date.parse(date + 'T00:00:00Z') +} + +/** + * A malformed date parses to NaN and every comparison against NaN is false, so + * a typo costs the dot rather than crashing the header; news.test.ts pins the + * catalog's dates so that can't go unnoticed. A future date reads as fresh. + */ +export function isFresh(e: NewsEntry, now: number): boolean { + return now - entryTime(e.date) < FRESH_MS +} + +export function freshCount(now: number, entries: NewsEntry[] = CHANGELOG): number { + return entries.filter((e) => isFresh(e, now)).length +} diff --git a/src/lib/pendingSwaps.test.ts b/src/lib/pendingSwaps.test.ts new file mode 100644 index 0000000..db954da --- /dev/null +++ b/src/lib/pendingSwaps.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { Hex } from 'viem' +import { + isSettled, + isSameUnresolvedSwap, + isUnresolved, + PENDING_SWAP_LINGER_MS, + PENDING_SWAP_STALE_POLL_MS, + PENDING_SWAP_STALE_MS, + pendingSwapTickAction, + pendingSwaps, + type PendingSwap, +} from './pendingSwaps' + +const hash = (n: number) => `0x${n.toString(16).padStart(64, '0')}` as Hex +const prefix = 'up33.pendingSwaps.v2.' +const base: PendingSwap = { + id: hash(1), + account: '0x3333333333333333333333333333333333333333', + tokenIn: '0x1111111111111111111111111111111111111111', + tokenOut: '0x2222222222222222222222222222222222222222', + amountIn: '1000', + inSym: 'ETH', + outSym: 'UP', + amountInDisp: '0.001', + createdAt: 1_000, + status: 'pending', +} + +test('pending swaps remain protected through stale, replacement, reload and storage failure', () => { + const backing = new Map() + let failWrites = false + let confirmAfterRead: Hex | null = null + const g = globalThis as { localStorage?: unknown } + g.localStorage = { + get length() { + return backing.size + }, + key: (i: number) => [...backing.keys()][i] ?? null, + getItem: (k: string) => { + const raw = backing.get(k) ?? null + if (confirmAfterRead && k === prefix + confirmAfterRead) { + backing.set(k, JSON.stringify({ ...base, id: confirmAfterRead, status: 'confirmed', settledAt: 2_000 })) + confirmAfterRead = null + } + return raw + }, + setItem: (k: string, v: string) => { + if (failWrites) throw new Error('quota exceeded') + backing.set(k, v) + }, + removeItem: (k: string) => void backing.delete(k), + } + + try { + // Corrupt/unversioned storage is a trust boundary, never a render crash. + backing.set(prefix + hash(99), JSON.stringify({ id: hash(99), status: 'pending', createdAt: 1 })) + + assert.equal(pendingSwaps.add(base), true) + assert.deepEqual(pendingSwaps.get().map((e) => e.id), [hash(1)]) + + pendingSwaps.settle(hash(1), 'stale') + assert.equal(isUnresolved(pendingSwaps.get()[0].status), true) + assert.equal(isSettled(pendingSwaps.get()[0].status), false) + assert.equal(isSameUnresolvedSwap(pendingSwaps.get()[0], base.account, base.tokenIn, base.tokenOut, 1000n), true) + assert.equal( + isSameUnresolvedSwap( + pendingSwaps.get()[0], + '0x4444444444444444444444444444444444444444', + base.tokenIn, + base.tokenOut, + 1000n, + ), + false, + ) + assert.equal(pendingSwaps.dismiss(hash(1)), false) + + // Tab A reads pending just before Tab B confirms it. A's stale verdict is + // memory-only, so it cannot overwrite B's terminal receipt on disk. + confirmAfterRead = hash(1) + pendingSwaps.settle(hash(1), 'stale') + assert.equal(JSON.parse(backing.get(prefix + hash(1))!).status, 'confirmed') + pendingSwaps.add({ ...base, id: hash(8), createdAt: 8_000 }) // re-sync disk + assert.equal(pendingSwaps.get().find((entry) => entry.id === hash(1))?.status, 'confirmed') + assert.equal(pendingSwaps.dismiss(hash(1)), true) + + pendingSwaps.add({ ...base, id: hash(2), createdAt: 2_000 }) + assert.equal(pendingSwaps.replaceHash(hash(2), hash(9), false), true) + assert.equal(pendingSwaps.get().find((entry) => entry.id === hash(9))?.status, 'pending') + pendingSwaps.settle(hash(9), 'confirmed') + pendingSwaps.settle(hash(9), 'stale') + assert.equal(pendingSwaps.get().find((entry) => entry.id === hash(9))?.status, 'confirmed') + + // Another tab's independently keyed add survives our next mutation. + const other = { ...base, id: hash(3), account: '0x4444444444444444444444444444444444444444' as const } + backing.set(prefix + other.id, JSON.stringify(other)) + assert.equal(pendingSwaps.add({ ...base, id: hash(4), createdAt: 4_000 }), true) + assert.deepEqual(new Set(pendingSwaps.get().map((e) => e.id)), new Set([hash(3), hash(4), hash(8), hash(9)])) + + assert.equal(pendingSwaps.dismiss(hash(9)), true) + assert.equal(pendingSwaps.get().some((e) => e.id === hash(9)), false) + + pendingSwaps.add({ ...base, id: hash(6), createdAt: 6_000 }) + assert.equal(pendingSwaps.replaceHash(hash(6), hash(7), true), true) + assert.equal(pendingSwaps.get().find((e) => e.id === hash(7))?.status, 'failed') + + // A quota failure is visible to the caller, while memory still protects + // this session from re-submission. + failWrites = true + assert.equal(pendingSwaps.add({ ...base, id: hash(5), createdAt: 5_000 }), false) + assert.equal(pendingSwaps.get().some((e) => e.id === hash(5)), true) + pendingSwaps.settle(hash(5), 'stale') + assert.equal(pendingSwaps.dismiss(hash(5)), false) + + const stale = { ...base, status: 'stale' as const } + assert.equal(pendingSwapTickAction(stale, PENDING_SWAP_STALE_POLL_MS - 1, 0), null) + assert.equal(pendingSwapTickAction(stale, PENDING_SWAP_STALE_POLL_MS, 0), 'poll') + assert.equal( + pendingSwapTickAction(base, base.createdAt + PENDING_SWAP_STALE_MS + 1, 0), + 'mark-stale-and-poll', + ) + assert.equal( + pendingSwapTickAction({ ...base, status: 'confirmed', settledAt: 2_000 }, 2_000 + PENDING_SWAP_LINGER_MS + 1, 0), + 'dismiss', + ) + assert.ok(PENDING_SWAP_STALE_MS > PENDING_SWAP_LINGER_MS) + } finally { + delete g.localStorage + } +}) diff --git a/src/lib/pendingSwaps.ts b/src/lib/pendingSwaps.ts new file mode 100644 index 0000000..bb7e3c7 --- /dev/null +++ b/src/lib/pendingSwaps.ts @@ -0,0 +1,262 @@ +// Persistent registry of unresolved SWAP transactions. Each hash gets its own +// localStorage key so concurrent tabs cannot overwrite each other's entries. +import { isAddress, isHex, type Address, type Hex } from 'viem' + +export type PendingSwapStatus = 'pending' | 'confirmed' | 'failed' | 'stale' + +export const PENDING_SWAP_STALE_MS = 10 * 60_000 +export const PENDING_SWAP_STALE_POLL_MS = 60_000 +export const PENDING_SWAP_LINGER_MS = 15_000 + +export type PendingSwap = { + id: Hex + account: Address + tokenIn: Address + tokenOut: Address + amountIn: string + inSym: string + outSym: string + amountInDisp: string + createdAt: number + status: PendingSwapStatus + settledAt?: number +} + +const PREFIX = 'up33.pendingSwaps.v2.' +const statuses: readonly PendingSwapStatus[] = ['pending', 'confirmed', 'failed', 'stale'] +const keyOf = (id: Hex) => PREFIX + id.toLowerCase() + +export const isSettled = (status: PendingSwapStatus) => status === 'confirmed' || status === 'failed' +export const isUnresolved = (status: PendingSwapStatus) => !isSettled(status) + +export function pendingSwapTickAction( + entry: PendingSwap, + now: number, + lastStalePoll: number, +): 'poll' | 'mark-stale-and-poll' | 'dismiss' | null { + if (entry.status === 'pending') { + return now - entry.createdAt > PENDING_SWAP_STALE_MS ? 'mark-stale-and-poll' : 'poll' + } + if (entry.status === 'stale') { + return now - lastStalePoll >= PENDING_SWAP_STALE_POLL_MS ? 'poll' : null + } + if (entry.settledAt && now - entry.settledAt > PENDING_SWAP_LINGER_MS) return 'dismiss' + return null +} + +export function isSameUnresolvedSwap( + entry: PendingSwap, + account: Address, + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, +): boolean { + return ( + isUnresolved(entry.status) && + entry.account.toLowerCase() === account.toLowerCase() && + entry.tokenIn.toLowerCase() === tokenIn.toLowerCase() && + entry.tokenOut.toLowerCase() === tokenOut.toLowerCase() && + entry.amountIn === amountIn.toString() + ) +} + +function validEntry(value: unknown): value is PendingSwap { + if (!value || typeof value !== 'object') return false + const entry = value as Record + return ( + typeof entry.id === 'string' && + isHex(entry.id) && + entry.id.length === 66 && + typeof entry.account === 'string' && + isAddress(entry.account) && + typeof entry.tokenIn === 'string' && + isAddress(entry.tokenIn) && + typeof entry.tokenOut === 'string' && + isAddress(entry.tokenOut) && + typeof entry.amountIn === 'string' && + /^[1-9]\d*$/.test(entry.amountIn) && + typeof entry.inSym === 'string' && + typeof entry.outSym === 'string' && + typeof entry.amountInDisp === 'string' && + typeof entry.createdAt === 'number' && + Number.isFinite(entry.createdAt) && + entry.createdAt >= 0 && + typeof entry.status === 'string' && + statuses.includes(entry.status as PendingSwapStatus) && + (entry.settledAt === undefined || + (typeof entry.settledAt === 'number' && Number.isFinite(entry.settledAt) && entry.settledAt >= 0)) + ) +} + +function parse(raw: string | null): PendingSwap | null { + if (!raw) return null + try { + const value = JSON.parse(raw) as unknown + return validEntry(value) ? value : null + } catch { + return null + } +} + +function loadAll(): PendingSwap[] | null { + try { + const found: PendingSwap[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (!key?.startsWith(PREFIX)) continue + const entry = parse(localStorage.getItem(key)) + if (entry && key === keyOf(entry.id)) found.push(entry) + } + return found.sort((a, b) => b.createdAt - a.createdAt) + } catch { + return null + } +} + +let entries: PendingSwap[] | null = null +let storageUsable = true +const subs = new Set<() => void>() +let storageHooked = false + +function all(): PendingSwap[] { + if (entries !== null) return entries + const stored = loadAll() + if (stored === null) storageUsable = false + entries = stored ?? [] + return entries +} + +function fresh(): PendingSwap[] { + if (!storageUsable) return entries ?? [] + const stored = loadAll() + if (stored === null) { + storageUsable = false + return entries ?? [] + } + entries = stored + return stored +} + +function write(entry: PendingSwap): boolean { + if (!storageUsable) return false + try { + // `stale` is only a local UI/polling state. Persisting it would let a tab + // with an old pending read overwrite another tab's just-written receipt. + const stored = entry.status === 'stale' ? { ...entry, status: 'pending' as const, settledAt: undefined } : entry + localStorage.setItem(keyOf(entry.id), JSON.stringify(stored)) + return true + } catch { + storageUsable = false + return false + } +} + +function remove(id: Hex): boolean { + if (!storageUsable) return false + try { + localStorage.removeItem(keyOf(id)) + return true + } catch { + storageUsable = false + return false + } +} + +function notify() { + for (const subscriber of subs) { + try { + subscriber() + } catch { + // A render observer must never break transaction tracking. + } + } +} + +export const pendingSwaps = { + add(entry: PendingSwap): boolean { + if (!validEntry(entry)) return false + const base = fresh() + if (base.some((current) => current.id === entry.id)) return true + entries = [entry, ...base].sort((a, b) => b.createdAt - a.createdAt) + const persisted = write(entry) + if (persisted) { + const stored = loadAll() + if (stored !== null) entries = stored + } + notify() + return persisted + }, + + settle(id: Hex, status: PendingSwapStatus): boolean { + const base = fresh() + const current = base.find((entry) => entry.id === id) + if (!current) return false + if (isSettled(current.status) || (current.status === 'stale' && status === 'pending')) return true + const next = { + ...current, + status, + settledAt: isSettled(status) ? current.settledAt ?? Date.now() : current.settledAt, + } + entries = base.map((entry) => (entry.id === id ? next : entry)) + if (status === 'stale') { + notify() + return true + } + const persisted = write(next) + notify() + return persisted + }, + + replaceHash(oldId: Hex, newId: Hex, cancelled: boolean): boolean { + const base = fresh() + const current = base.find((entry) => entry.id === oldId) + if (!current || oldId === newId) return !!current + const next: PendingSwap = { + ...current, + id: newId, + status: cancelled ? 'failed' : current.status, + settledAt: cancelled ? Date.now() : current.settledAt, + } + entries = [next, ...base.filter((entry) => entry.id !== oldId && entry.id !== newId)] + const persisted = write(next) + const removed = persisted && remove(oldId) + notify() + return persisted && removed + }, + + dismiss(id: Hex): boolean { + const base = fresh() + const current = base.find((entry) => entry.id === id) + if (!current || !isSettled(current.status)) return false + entries = base.filter((entry) => entry.id !== id) + const persisted = remove(id) + notify() + return persisted + }, + + get(): PendingSwap[] { + return all() + }, + + subscribe(subscriber: () => void): () => void { + if (!storageHooked && typeof window !== 'undefined') { + storageHooked = true + window.addEventListener('storage', (event) => { + if (!event.key?.startsWith(PREFIX) || !storageUsable) return + const stored = loadAll() + if (stored === null) storageUsable = false + else entries = stored + notify() + }) + } + // Attach the storage listener before the read. React re-checks getSnapshot + // after subscribe, closing the get→subscribe race. + if (storageUsable) { + const stored = loadAll() + if (stored === null) storageUsable = false + else entries = stored + } + subs.add(subscriber) + return () => subs.delete(subscriber) + }, +} diff --git a/src/lib/popover.test.ts b/src/lib/popover.test.ts new file mode 100644 index 0000000..15903c9 --- /dev/null +++ b/src/lib/popover.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { popoverTop } from './popover' + +const CARD = 118 // the address card: header + three rows, measured at 390px +const VH = 740 // a phone in portrait + +test('drops below the trigger when there is room', () => { + assert.equal(popoverTop({ top: 200, bottom: 216 }, CARD, VH), 222) +}) + +// the case that shipped broken: a row near the bottom of the screen put the +// card 103px past the fold, where close-on-scroll made it unreachable +test('flips above the trigger rather than off the bottom', () => { + const top = popoverTop({ top: 702, bottom: 718 }, CARD, VH) + assert.equal(top, 702 - CARD - 6) + assert.ok(top + CARD <= VH, 'card must end above the fold') + assert.ok(top >= 0, 'and must not start above the top edge') +}) + +test('clamps when neither side fits', () => { + // trigger fills the screen: no room below, and above would be off the top + const top = popoverTop({ top: 4, bottom: 700 }, CARD, VH) + assert.equal(top, VH - CARD - 8) + assert.ok(top >= 8) +}) + +test('a card taller than the viewport still starts on screen', () => { + assert.equal(popoverTop({ top: 100, bottom: 116 }, 2000, VH), 8) +}) + +// whatever the trigger, the top edge is always visible — the copy rows below it +// may be cut off on an absurdly short viewport, but the card is never invisible +test('the card always starts inside the viewport', () => { + for (const vh of [320, 500, 740, 900, 1400]) + for (let y = 0; y < vh; y += 17) { + const top = popoverTop({ top: y, bottom: y + 16 }, CARD, vh) + assert.ok(top >= 8, `vh=${vh} y=${y} -> ${top}`) + assert.ok(top < vh, `vh=${vh} y=${y} -> ${top}`) + } +}) diff --git a/src/lib/popover.ts b/src/lib/popover.ts new file mode 100644 index 0000000..ceb3fb9 --- /dev/null +++ b/src/lib/popover.ts @@ -0,0 +1,22 @@ +// Placement for this app's fixed popovers (PairAddrs' address card). +// +// `position: fixed` plus close-on-scroll makes an off-screen card unreachable +// rather than merely awkward: scrolling toward it is what dismisses it. So the +// only acceptable outcome is wholly on screen — prefer below the trigger, flip +// above when the card doesn't fit there, and clamp when neither side fits +// (a viewport shorter than the card, or a trigger pinned to the top edge). + +/** vertical position for a `height`-tall card anchored to `trigger` */ +export function popoverTop( + trigger: { top: number; bottom: number }, + height: number, + viewportH: number, + gap = 8, + offset = 6, +): number { + const below = trigger.bottom + offset + if (below + height <= viewportH - gap) return below + const above = trigger.top - height - offset + if (above >= gap) return above + return Math.max(gap, viewportH - height - gap) +} diff --git a/src/lib/posmetrics.test.ts b/src/lib/posmetrics.test.ts new file mode 100644 index 0000000..d9c5181 --- /dev/null +++ b/src/lib/posmetrics.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { ClPool, ClPosition } from '../types' +import { compareClPositionDisplay } from './posmetrics' + +const position = (tokenId: bigint, protocol: ClPool['protocol'], staked: boolean) => + ({ tokenId, staked, pool: { protocol } }) as ClPosition + +test('unstaking a CL position does not change display order', () => { + const values = new Map([[1n, 100], [2n, 200]]) + const ids = (positions: ClPosition[]) => + [...positions] + .sort((a, b) => compareClPositionDisplay(a, b, values.get(a.tokenId)!, values.get(b.tokenId)!)) + .map((position) => position.tokenId) + + assert.deepEqual(ids([position(1n, 'up33', true), position(2n, 'up33', false)]), [2n, 1n]) + assert.deepEqual(ids([position(1n, 'up33', false), position(2n, 'up33', false)]), [2n, 1n]) +}) diff --git a/src/lib/posmetrics.ts b/src/lib/posmetrics.ts index 4df6570..243be1c 100644 --- a/src/lib/posmetrics.ts +++ b/src/lib/posmetrics.ts @@ -28,6 +28,22 @@ export type ClMetrics = { earning: Earning } +/** Stable display order: staking changes yield, not the position's place. */ +export function compareClPositionDisplay( + a: ClPosition, + b: ClPosition, + valueA: number | null, + valueB: number | null, +): number { + const protocolA = a.pool.protocol === 'up33' ? 0 : 1 + const protocolB = b.pool.protocol === 'up33' ? 0 : 1 + const protocol = protocolA - protocolB + if (protocol) return protocol + const value = (valueB ?? -1) - (valueA ?? -1) + if (value) return value + return a.tokenId === b.tokenId ? 0 : a.tokenId < b.tokenId ? -1 : 1 +} + const DAY = 86_400 const YEAR_DAYS = 365 diff --git a/src/lib/refreshTip.test.ts b/src/lib/refreshTip.test.ts new file mode 100644 index 0000000..84f7790 --- /dev/null +++ b/src/lib/refreshTip.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { en } from '../i18n/en' +import { zh } from '../i18n/zh' + +test('generic refresh help stays separate from the solver refresh cap', () => { + assert.equal(en.swap.refreshTip, 'quotes refresh every {{s}}s — click to refresh now') + assert.match(en.swap.solverRefreshTip, /3 automatic refreshes/) + assert.equal(zh.swap.refreshTip, '报价每 {{s}} 秒自动刷新 — 点击立即刷新') + assert.match(zh.swap.solverRefreshTip, /自动刷新 3 次后暂停/) +}) diff --git a/src/lib/solver.ts b/src/lib/solver.ts new file mode 100644 index 0000000..93483c9 --- /dev/null +++ b/src/lib/solver.ts @@ -0,0 +1,153 @@ +// Solver-backed swap quotes. The deployed router (ENV.solverUrl → +// solver.lp-terminal.xyz) quotes across every venue it routes — Uniswap +// v2/v3/v4, UP33 CL + Solidly, and supported V3 forks — with split +// allocation, and returns a ready-to-sign 0x-Settler transaction. +// Two rules are load-bearing: the tx is signed AS-IS, and allowanceTarget +// (the AllowanceHolder) is the ONLY approval target — never the settler +// address. +import { getAddress, type Address, type Hex } from 'viem' +import { ENV } from '../config/env' +import { parseSolverMidAmountOut, parseSolverPriceImpactBps } from './solverResponse' + +export type SolverHop = { + protocol: string + /** CL-family hops carry feePpm; v2-family hops carry feeBps */ + feePpm?: number + feeBps?: number +} +export type SolverLeg = { shareBps: number; hops: SolverHop[] } + +export type SolverQuote = { + block: number + amountIn: bigint + amountOutGross: bigint + /** full-size executable rate versus the solver's same-block 1% executable + * fair-price quote. The fee both sides paid cancels: this is the SIZE + * penalty, not the all-in cost — that comes from midAmountOut. */ + priceImpactBps: number | null + /** what amountIn fetches at the fee-free price — the one denominator every + * quote card's cost is measured against. Null when the probe cannot run. */ + midAmountOut: bigint | null + /** after the solver-side terminal fee — comparable to DirectCandidate.amountOut */ + amountOutNet: bigint + /** the floor embedded in tx (net of fee, at the requested slippageBps) */ + minAmountOutNet: bigint + feeBps: number + deadline: number + route: SolverLeg[] + tx: { to: Address; value: bigint; data: Hex } | null + /** AllowanceHolder; null for native input, absent on quote-only requests */ + allowanceTarget: Address | null +} + +type WireHop = { protocol: string; feePpm?: number; feeBps?: number } +type WireQuote = { + block: number + amountIn: string + amountOutGross: string + priceImpactBps?: unknown + midAmountOut: unknown + amountOutNet: string + minAmountOutNet: string + feeBps: number + deadline: number + route: { shareBps: number; hops: WireHop[] }[] + tx?: { to: string; value: string; data: string } + allowanceTarget?: string | null + error?: string +} + +// A cold three-hop topology miss probes up to ten unique token pairs. Leave +// enough client-side margin for public-RPC jitter; the server retains its +// separate 120s hard ceiling. +const SOLVER_TIMEOUT_MS = 20_000 + +export async function fetchSolverQuote(args: { + tokenIn: Address + tokenOut: Address + amountIn: bigint + slippageBps: number + recipient?: Address + /** submitting account; defaults server-side to recipient */ + sender?: Address + /** per-request terminal-fee override (zap's rate); omitted = server default */ + feeBps?: number +}): Promise { + const res = await fetch(`${ENV.solverUrl}/quote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(SOLVER_TIMEOUT_MS), + body: JSON.stringify({ + tokenIn: args.tokenIn, + tokenOut: args.tokenOut, + amountIn: args.amountIn.toString(), + slippageBps: args.slippageBps, + deadlineSeconds: 600, + ...(args.recipient ? { recipient: args.recipient } : {}), + ...(args.sender ? { sender: args.sender } : {}), + ...(args.feeBps !== undefined ? { feeBps: args.feeBps } : {}), + }), + }) + const body = (await res.json()) as WireQuote + if (!res.ok) throw new Error(body.error ?? `solver HTTP ${res.status}`) + return { + block: body.block, + amountIn: BigInt(body.amountIn), + amountOutGross: BigInt(body.amountOutGross), + priceImpactBps: parseSolverPriceImpactBps(body.priceImpactBps), + midAmountOut: parseSolverMidAmountOut(body.midAmountOut), + amountOutNet: BigInt(body.amountOutNet), + minAmountOutNet: BigInt(body.minAmountOutNet), + feeBps: body.feeBps, + deadline: body.deadline, + route: body.route.map((leg) => ({ + shareBps: leg.shareBps, + hops: leg.hops.map(({ protocol, feePpm, feeBps }) => ({ protocol, feePpm, feeBps })), + })), + tx: body.tx + ? { to: getAddress(body.tx.to), value: BigInt(body.tx.value), data: body.tx.data as Hex } + : null, + allowanceTarget: body.allowanceTarget ? getAddress(body.allowanceTarget) : null, + } +} + +/** share-weighted venue fee across the route in bps — the volatility prior + * autoSlippage expects (a multihop leg's fee is the sum of its hops') */ +export function solverVenueFeeBps(quote: SolverQuote): number { + let acc = 0 + for (const leg of quote.route) { + acc += leg.shareBps * leg.hops.reduce((sum, hop) => sum + (hop.feePpm ?? (hop.feeBps ?? 0) * 100), 0) + } + return acc / 10_000 / 100 +} + +const VENUES: Record = { + univ2: 'UNI V2', + univ3: 'UNI V3', + univ4: 'UNI V4', + up33cl: 'UP33 CL', + up33v2: 'UP33 V2', + pancakev3: 'PANCAKE V3', + swaphoodv3: 'SWAPHOOD V3', + gigadexv3: 'GIGADEX CL', + sushiv3: 'SUSHI V3', + robinswapv3: 'ROBINSWAP V3', + sheriff: 'SHERIFF', +} + +/** display name for a solver hop protocol (falls back to the raw id) */ +export function venueLabel(protocol: string): string { + return VENUES[protocol] ?? protocol.toUpperCase() +} + +/** a leg's share for display — sub-0.5% legs read "<1" rather than "0" */ +export function legSharePct(shareBps: number): string { + return shareBps < 50 ? '<1' : String(Math.round(shareBps / 100)) +} + +/** compact route summary: "68% UNI V3 + 23% UNI V4 + 9% UP33 CL→UNI V3" */ +export function solverRouteLabel(quote: SolverQuote): string { + return quote.route + .map((leg) => `${legSharePct(leg.shareBps)}% ${leg.hops.map((hop) => venueLabel(hop.protocol)).join('→')}`) + .join(' + ') +} diff --git a/src/lib/solverPreflight.test.ts b/src/lib/solverPreflight.test.ts new file mode 100644 index 0000000..006ae5d --- /dev/null +++ b/src/lib/solverPreflight.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { Address, Hex } from 'viem' +import { preflightSolverTransaction } from './solverPreflight' + +const account = '0x0000000000000000000000000000000000000001' as Address +const tx = { + to: '0x0000000000000000000000000000000000000002' as Address, + data: '0x1234' as Hex, + value: 7n, +} + +test('preflights the exact solver transaction and adds 20% gas headroom', async () => { + let request: unknown + const client = { + estimateGas: async (next: unknown) => { + request = next + return 101n + }, + } + + assert.equal(await preflightSolverTransaction(client, account, tx), 122n) + assert.deepEqual(request, { account, to: tx.to, data: tx.data, value: tx.value }) +}) + +test('fails closed when the solver transaction preflight reverts', async () => { + const failure = new Error('execution reverted') + const client = { + estimateGas: async () => { + throw failure + }, + } + + await assert.rejects(preflightSolverTransaction(client, account, tx), failure) +}) diff --git a/src/lib/solverPreflight.ts b/src/lib/solverPreflight.ts new file mode 100644 index 0000000..71f9ba9 --- /dev/null +++ b/src/lib/solverPreflight.ts @@ -0,0 +1,23 @@ +import type { Address, Hex } from 'viem' + +type SolverTransaction = { + to: Address + data: Hex + value: bigint +} + +type GasEstimator = { + estimateGas(request: SolverTransaction & { account: Address }): Promise +} + +/** Execute the final solver calldata as an estimate, then return a bounded + * gas limit for the identical transaction. A revert is deliberately not + * caught: preflight failure must stop broadcast. */ +export async function preflightSolverTransaction( + client: GasEstimator, + account: Address, + tx: SolverTransaction, +): Promise { + const estimated = await client.estimateGas({ account, to: tx.to, data: tx.data, value: tx.value }) + return (estimated * 6n + 4n) / 5n +} diff --git a/src/lib/solverRefresh.test.ts b/src/lib/solverRefresh.test.ts new file mode 100644 index 0000000..deb2d99 --- /dev/null +++ b/src/lib/solverRefresh.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { QueryClient, QueryObserver } from '@tanstack/react-query' +import { + ManualRefreshGate, + quoteDataIsStale, + selectUsableQuoteData, + solverQuoteCanAutoRefresh, + solverQuoteNeedsManualRefresh, + solverQuoteRefetchInterval, +} from './solverRefresh' + +test('cached solver and direct data leave selection and request refresh at the age boundary', () => { + const solver = { source: 'solver' } + const direct = { source: 'direct' } + const select = (solverDataUpdatedAt = 1_001, directDataUpdatedAt = 1_001, solverError = false, directError = false) => + selectUsableQuoteData({ + solverData: solver, + directData: direct, + solverError, + directError, + solverDataUpdatedAt, + directDataUpdatedAt, + now: 16_000, + }) + + assert.deepEqual(select(), { solver, direct, hasStaleData: false }) + assert.deepEqual(select(1_000), { solver: null, direct, hasStaleData: true }) + assert.deepEqual(select(1_001, 1_000), { solver, direct: null, hasStaleData: true }) + assert.deepEqual(select(1_001, 1_001, true), { solver: null, direct, hasStaleData: false }) + assert.deepEqual(select(1_001, 1_001, false, true), { solver, direct: null, hasStaleData: false }) + assert.equal(quoteDataIsStale(1_000, 15_999), false) + assert.equal(quoteDataIsStale(1_000, 16_000), true) +}) + +test('solver auto-refreshes three times, then requires a fresh manual quote', () => { + for (let settled = 0; settled <= 3; settled += 1) { + assert.equal(solverQuoteRefetchInterval({ dataUpdateCount: settled, errorUpdateCount: 0 }), 15_000) + } + assert.equal(solverQuoteRefetchInterval({ dataUpdateCount: 4, errorUpdateCount: 0 }), false) + assert.equal(solverQuoteRefetchInterval({ dataUpdateCount: 0, errorUpdateCount: 4 }), false) + assert.equal(solverQuoteRefetchInterval({ dataUpdateCount: 4, errorUpdateCount: 0 }, 1), 15_000) + assert.equal(solverQuoteRefetchInterval({ dataUpdateCount: 5, errorUpdateCount: 0 }, 1), false) + + assert.equal(solverQuoteNeedsManualRefresh(true, true), false) + assert.equal(solverQuoteNeedsManualRefresh(true, false), true) + assert.equal(solverQuoteNeedsManualRefresh(false, false), false) +}) + +test('concurrent manual refreshes share one request and one settlement', async () => { + const gate = new ManualRefreshGate() + const query = {} + let calls = 0 + let settlements = 0 + let resolve!: (value: number) => void + const refetch = () => { + calls += 1 + return new Promise((done) => { + resolve = done + }).then((value) => { + settlements += 1 + return value + }) + } + + const first = gate.run(query, refetch) + const second = gate.run(query, refetch) + + assert.equal(first, second) + assert.equal(calls, 1) + resolve(42) + assert.equal(await first, 42) + assert.equal(await second, 42) + assert.equal(settlements, 1) + + const third = gate.run(query, async () => { + calls += 1 + settlements += 1 + return 43 + }) + assert.equal(await third, 43) + assert.equal(calls, 2) + assert.equal(settlements, 2) +}) + +test('an exhausted solver ignores automatic invalidation but still refetches manually', async () => { + const client = new QueryClient() + const solverKey = ['solverQuote', 'test'] as const + const directKey = ['directQuote', 'test'] as const + for (let settled = 0; settled < 4; settled += 1) client.setQueryData(solverKey, settled) + client.setQueryData(directKey, 0) + let solverCalls = 0 + let directCalls = 0 + const solver = new QueryObserver(client, { + queryKey: solverKey, + queryFn: async () => ++solverCalls, + enabled: (query) => solverQuoteCanAutoRefresh(query.state), + staleTime: Infinity, + }) + const direct = new QueryObserver(client, { + queryKey: directKey, + queryFn: async () => ++directCalls, + staleTime: Infinity, + }) + const stopSolver = solver.subscribe(() => {}) + const stopDirect = direct.subscribe(() => {}) + + try { + await client.invalidateQueries() + assert.equal(solverCalls, 0) + assert.equal(directCalls, 1) + await solver.refetch() + assert.equal(solverCalls, 1) + } finally { + stopSolver() + stopDirect() + client.clear() + } +}) diff --git a/src/lib/solverRefresh.ts b/src/lib/solverRefresh.ts new file mode 100644 index 0000000..e279bce --- /dev/null +++ b/src/lib/solverRefresh.ts @@ -0,0 +1,70 @@ +export const SOLVER_QUOTE_REFRESH_MS = 15_000 +export const SOLVER_AUTO_REFRESHES = 3 + +type SettledQuoteCounts = { dataUpdateCount: number; errorUpdateCount: number } + +export class ManualRefreshGate { + private readonly inFlight = new WeakMap>() + + run(query: object, refetch: () => Promise): Promise { + const active = this.inFlight.get(query) + if (active) return active as Promise + + const guarded = refetch().finally(() => { + if (this.inFlight.get(query) === guarded) this.inFlight.delete(query) + }) + this.inFlight.set(query, guarded) + return guarded + } +} + +export const solverQuoteAutoRefreshExhausted = ( + state: SettledQuoteCounts, + manualRefreshes = 0, +): boolean => + state.dataUpdateCount + state.errorUpdateCount - manualRefreshes > SOLVER_AUTO_REFRESHES + +export const solverQuoteCanAutoRefresh = ( + state: SettledQuoteCounts, + manualRefreshes = 0, +): boolean => !solverQuoteAutoRefreshExhausted(state, manualRefreshes) + +export const solverQuoteRefetchInterval = ( + state: SettledQuoteCounts, + manualRefreshes = 0, +): number | false => + solverQuoteCanAutoRefresh(state, manualRefreshes) ? SOLVER_QUOTE_REFRESH_MS : false + +export const quoteDataIsStale = (lastSuccessfulQuoteAt: number, now: number): boolean => + lastSuccessfulQuoteAt <= 0 || now - lastSuccessfulQuoteAt >= SOLVER_QUOTE_REFRESH_MS + +export const solverQuoteNeedsManualRefresh = ( + autoRefreshExhausted: boolean, + hasUsableQuote: boolean, +): boolean => autoRefreshExhausted && !hasUsableQuote + +export const selectUsableQuoteData = ({ + solverData, + directData, + solverError, + directError, + solverDataUpdatedAt, + directDataUpdatedAt, + now, +}: { + solverData: Solver | null | undefined + directData: Direct | null | undefined + solverError: boolean + directError: boolean + solverDataUpdatedAt: number + directDataUpdatedAt: number + now: number +}): { solver: Solver | null; direct: Direct | null; hasStaleData: boolean } => { + const solverStale = solverData != null && quoteDataIsStale(solverDataUpdatedAt, now) + const directStale = directData != null && quoteDataIsStale(directDataUpdatedAt, now) + return { + solver: solverError || solverStale ? null : (solverData ?? null), + direct: directError || directStale ? null : (directData ?? null), + hasStaleData: solverStale || directStale, + } +} diff --git a/src/lib/solverResponse.test.ts b/src/lib/solverResponse.test.ts new file mode 100644 index 0000000..41a1441 --- /dev/null +++ b/src/lib/solverResponse.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseSolverMidAmountOut, parseSolverPriceImpactBps } from './solverResponse' + +test('validates nullable solver price impact basis points', () => { + assert.equal(parseSolverPriceImpactBps(undefined), null) + assert.equal(parseSolverPriceImpactBps(null), null) + assert.equal(parseSolverPriceImpactBps(0), 0) + assert.equal(parseSolverPriceImpactBps(10_000), 10_000) + + for (const invalid of ['8', -1, 1.5, 10_001]) { + assert.throws(() => parseSolverPriceImpactBps(invalid), /invalid solver priceImpactBps/) + } +}) + +test('validates the nullable solver cost baseline', () => { + assert.equal(parseSolverMidAmountOut('15631003702256628545100'), 15631003702256628545100n) + assert.equal(parseSolverMidAmountOut(null), null) + for (const invalid of [undefined, 1, '', '0', '-1', '1.5', '0x1f', '12a', '007']) { + assert.throws(() => parseSolverMidAmountOut(invalid), /invalid solver midAmountOut/) + } +}) diff --git a/src/lib/solverResponse.ts b/src/lib/solverResponse.ts new file mode 100644 index 0000000..155d9d8 --- /dev/null +++ b/src/lib/solverResponse.ts @@ -0,0 +1,16 @@ +export function parseSolverPriceImpactBps(value: unknown): number | null { + if (value === undefined || value === null) return null + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 10_000) { + throw new Error('invalid solver priceImpactBps') + } + return value +} + +/** The fee-free baseline quote costs are divided by; null means no probe. */ +export function parseSolverMidAmountOut(value: unknown): bigint | null { + if (value === null) return null + if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) { + throw new Error('invalid solver midAmountOut') + } + return BigInt(value) +} diff --git a/src/lib/stake.ts b/src/lib/stake.ts new file mode 100644 index 0000000..672e694 --- /dev/null +++ b/src/lib/stake.ts @@ -0,0 +1,97 @@ +// Shared gauge-staking primitives — one implementation behind both the manual +// STAKE buttons (PositionsTab) and the auto-stake continuation after a Zap/Pair. +// Both stakers NEVER throw: staking always runs after value has already moved +// (position minted / LP received), so an error here must surface as a log line +// and a `false`, never as a rejection that makes the whole flow look failed. +import { readContract, writeContract } from 'wagmi/actions' +import { parseAbiItem, parseEventLogs, zeroAddress, type Address, type TransactionReceipt } from 'viem' +import { clGaugeAbi, clPmAbi, v2GaugeAbi } from '../abi' +import { CHAIN_ID } from '../config/addresses' +import { wagmiConfig } from '../config/wagmi' +import { t } from '../i18n' +import { ensureAllowance, shortErr, step } from './tx' +import { txlog } from './txlog' + +const ERC721_TRANSFER = parseAbiItem( + 'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)', +) + +/** Stake a CL position NFT into its gauge: approve the id if needed, then deposit. */ +export async function stakeClNft(gauge: Address, npm: Address, tokenId: bigint): Promise { + try { + const approved = await readContract(wagmiConfig, { + abi: clPmAbi, + address: npm, + functionName: 'getApproved', + args: [tokenId], + chainId: CHAIN_ID, + }) + if (approved.toLowerCase() !== gauge.toLowerCase()) { + const ok = await step(t('pos.stApproveNft', { id: tokenId.toString() }), () => + writeContract(wagmiConfig, { + abi: clPmAbi, + address: npm, + functionName: 'approve', + args: [gauge, tokenId], + chainId: CHAIN_ID, + }), + ) + if (!ok) return false + } + const ok = await step(t('pos.stStake', { id: tokenId.toString() }), () => + writeContract(wagmiConfig, { + abi: clGaugeAbi, + address: gauge, + functionName: 'deposit', + args: [tokenId], + chainId: CHAIN_ID, + }), + ) + return ok !== null + } catch (e) { + txlog.push('err', `${t('pos.stStake', { id: tokenId.toString() })} — ${shortErr(e)}`) + return false + } +} + +/** Stake v2 LP tokens into their gauge: approve exact, then deposit. */ +export async function stakeV2Lp( + pool: Address, + gauge: Address, + lp: bigint, + user: Address, + pair: string, +): Promise { + if (lp === 0n) return false + try { + if (!(await ensureAllowance(pool, user, gauge, lp, 'LP'))) return false + const ok = await step(t('pos.stStakeLp', { pair }), () => + writeContract(wagmiConfig, { + abi: v2GaugeAbi, + address: gauge, + functionName: 'deposit', + args: [lp], + chainId: CHAIN_ID, + }), + ) + return ok !== null + } catch (e) { + txlog.push('err', `${t('pos.stStakeLp', { pair })} — ${shortErr(e)}`) + return false + } +} + +/** The tokenId minted to `user` in a CL mint receipt (NPM ERC-721 Transfer from 0x0). */ +export function mintedTokenId(rcpt: TransactionReceipt, npm: Address, user: Address): bigint | null { + const logs = parseEventLogs({ abi: [ERC721_TRANSFER], logs: rcpt.logs }) + for (const l of logs) { + if ( + l.address.toLowerCase() === npm.toLowerCase() && + l.args.from.toLowerCase() === zeroAddress && + l.args.to.toLowerCase() === user.toLowerCase() + ) { + return l.args.tokenId + } + } + return null +} diff --git a/src/lib/swapExec.ts b/src/lib/swapExec.ts new file mode 100644 index 0000000..89d70a7 --- /dev/null +++ b/src/lib/swapExec.ts @@ -0,0 +1,218 @@ +// Direct quote-to-confirmed swap execution shared by MARKET and ZAP. +import { getPublicClient, sendTransaction } from 'wagmi/actions' +import { getAddress, type Address, type Hex, type ReplacementReason, type TransactionReceipt } from 'viem' +import { CHAIN_ID } from '../config/addresses' +import { swapFee } from '../config/env' +import { wagmiConfig } from '../config/wagmi' +import { t } from '../i18n' +import { buildDirectTransaction, erc20Of, isNative, quoteDirectRoute, type DirectRoute } from './directSwap' +import { fetchSolverQuote } from './solver' +import { preflightSolverTransaction } from './solverPreflight' +import { deadline, ensureAllowance, receivedOf, step, type StepFailWhy } from './tx' + +/** a failure a bigger slippage tolerance could have absorbed (pre-flight + * re-quote fell below the caller's minimum) — callers key retry advice on it */ +export class SlippageError extends Error {} + +export type SwapExecutionIntent = { + route: DirectRoute + tokenIn: Address + tokenOut: Address + amountIn: bigint + minimumAmountOut: bigint + sender: Address + recipient: Address + inputSymbol: string + label: string + /** terminal fee for this execution; defaults to swapFee() — zap passes zapFee() */ + fee?: { bps: number; receiver: Address } + /** fires when the swap tx is broadcast (hash in hand) — before it confirms */ + onSubmitted?: (hash: Hex) => void + onReplaced?: (oldHash: Hex, newHash: Hex, reason: ReplacementReason) => void + /** hears WHY the send step returned null (rejection vs on-chain revert) */ + onStepFail?: (why: StepFailWhy) => void +} + +type ConfirmedSwap = { + receipt: TransactionReceipt + output: { kind: 'erc20'; token: Address; amount: bigint } | { kind: 'native' } +} + +type PreparedSwap = { + to: Address + data: Hex + value: bigint + spender: Address | null + inputToken: Address | null + outputToken: Address | null +} + +async function prepareSwap(args: SwapExecutionIntent): Promise { + const fee = args.fee ?? swapFee() + const client = getPublicClient(wagmiConfig, { chainId: CHAIN_ID }) + const quote = await quoteDirectRoute( + client, + args.route, + args.tokenIn, + args.tokenOut, + args.amountIn, + fee.bps, + ) + if (quote.amountOut < args.minimumAmountOut) throw new SlippageError(t('swap.errQuoteMoved')) + return buildDirectTransaction({ + tokenIn: args.tokenIn, + tokenOut: args.tokenOut, + amountIn: args.amountIn, + minimumAmountOut: args.minimumAmountOut, + recipient: args.recipient, + route: args.route, + deadline: deadline(), + fee, + }) +} + +export async function executeSwap(args: SwapExecutionIntent): Promise { + let prepared = await prepareSwap(args) + + if (prepared.spender !== null) { + if (prepared.inputToken === null) throw new Error('ERC-20 swap is missing its approval token') + const allowance = await ensureAllowance( + prepared.inputToken, + args.sender, + prepared.spender, + args.amountIn, + args.inputSymbol, + ) + if (!allowance) return null + if (allowance === 'approved') { + const approvedSpender = prepared.spender + const approvedToken = prepared.inputToken + prepared = await prepareSwap(args) + if ( + prepared.spender === null || + prepared.inputToken === null || + getAddress(prepared.spender) !== getAddress(approvedSpender) || + getAddress(prepared.inputToken) !== getAddress(approvedToken) + ) { + throw new Error('direct allowance target changed after approval') + } + } + } + + const receipt = await step( + args.label, + () => + sendTransaction(wagmiConfig, { + account: args.sender, + to: prepared.to, + data: prepared.data, + value: prepared.value, + chainId: CHAIN_ID, + }), + { onSubmitted: args.onSubmitted, onReplaced: args.onReplaced, onFail: args.onStepFail }, + ) + if (!receipt) return null + if (prepared.outputToken === null) return { receipt, output: { kind: 'native' } } + + const amount = receivedOf(receipt, prepared.outputToken, args.recipient) + if (amount < args.minimumAmountOut) { + throw new Error( + t('swap.receivedBelowMinimum', { + got: amount.toString(), + min: args.minimumAmountOut.toString(), + }), + ) + } + return { receipt, output: { kind: 'erc20', token: prepared.outputToken, amount } } +} + +export type SolverSwapIntent = { + tokenIn: Address + tokenOut: Address + amountIn: bigint + /** tolerance the execution-time quote embeds into the Settler tx */ + slippageBps: number + /** the floor the user was shown; execution re-quotes and refuses below it */ + minimumAmountOut: bigint + sender: Address + recipient: Address + inputSymbol: string + label: string + /** terminal-fee override the solver charges server-side (zap's 9); absent = server default */ + feeBps?: number + /** fires when the swap tx is broadcast (hash in hand) — before it confirms */ + onSubmitted?: (hash: Hex) => void + onReplaced?: (oldHash: Hex, newHash: Hex, reason: ReplacementReason) => void + onStepFail?: (why: StepFailWhy) => void +} + +/** Solver-routed swap: fetch a fresh quote WITH a tx, approve the + * AllowanceHolder for exactly amountIn, send the tx as-is. Mirrors + * executeSwap's shape — pre-flight re-quote, post-approval re-prepare with + * a spender-consistency check, Transfer-log delivery verification. */ +export async function executeSolverSwap(args: SolverSwapIntent): Promise { + const fresh = () => + fetchSolverQuote({ + tokenIn: args.tokenIn, + tokenOut: args.tokenOut, + amountIn: args.amountIn, + slippageBps: args.slippageBps, + recipient: args.recipient, + sender: args.sender, + feeBps: args.feeBps, + }) + let quote = await fresh() + if (quote.amountOutNet < args.minimumAmountOut) throw new SlippageError(t('swap.errQuoteMoved')) + + if (quote.allowanceTarget !== null) { + const allowance = await ensureAllowance( + erc20Of(args.tokenIn), + args.sender, + quote.allowanceTarget, + args.amountIn, + args.inputSymbol, + ) + if (!allowance) return null + if (allowance === 'approved') { + const approvedSpender = quote.allowanceTarget + quote = await fresh() + if (quote.amountOutNet < args.minimumAmountOut) throw new SlippageError(t('swap.errQuoteMoved')) + if (quote.allowanceTarget === null || getAddress(quote.allowanceTarget) !== getAddress(approvedSpender)) { + throw new Error('solver allowance target changed after approval') + } + } + } + + const tx = quote.tx + if (!tx) throw new Error('solver quote carried no transaction') + const client = getPublicClient(wagmiConfig, { chainId: CHAIN_ID }) + const receipt = await step( + args.label, + async () => { + const gas = await preflightSolverTransaction(client, args.sender, tx) + return sendTransaction(wagmiConfig, { + account: args.sender, + to: tx.to, + data: tx.data, + value: tx.value, + gas, + chainId: CHAIN_ID, + }) + }, + { onSubmitted: args.onSubmitted, onReplaced: args.onReplaced, onFail: args.onStepFail }, + ) + if (!receipt) return null + if (isNative(args.tokenOut)) return { receipt, output: { kind: 'native' } } + + const token = erc20Of(args.tokenOut) + const amount = receivedOf(receipt, token, args.recipient) + if (amount < quote.minAmountOutNet) { + throw new Error( + t('swap.receivedBelowMinimum', { + got: amount.toString(), + min: quote.minAmountOutNet.toString(), + }), + ) + } + return { receipt, output: { kind: 'erc20', token, amount } } +} diff --git a/src/lib/swapGate.test.ts b/src/lib/swapGate.test.ts new file mode 100644 index 0000000..8dd2011 --- /dev/null +++ b/src/lib/swapGate.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + allInCostBps, + AUTO_IMPACT_LIMIT_BPS, + autoSlippage, + clampSlippageBps, + MAX_SLIPPAGE_BPS, + MIN_SLIPPAGE_BPS, + retrySlippage, + slippagePctToBps, +} from './swapGate' + +test('auto slippage = impact + pool fee + max(0.3%, half the impact)', () => { + // zero impact, no fee: just the 0.3% pad + assert.deepEqual(autoSlippage(0, 0), { bps: 30, tone: 'green' }) + // the pool fee tier rides along as a volatility prior (1% pool -> +1%) + assert.deepEqual(autoSlippage(0, 100), { bps: 130, tone: 'amber' }) + assert.deepEqual(autoSlippage(20, 30), { bps: 80, tone: 'green' }) + // half-the-impact overtakes the pad once impact > 0.6% + assert.deepEqual(autoSlippage(100, 30), { bps: 180, tone: 'amber' }) + assert.deepEqual(autoSlippage(300, 100), { bps: 550, tone: 'red' }) + // no 3% ceiling — a thin/volatile token gets real headroom + assert.deepEqual(autoSlippage(800, 100), { bps: 1300, tone: 'red' }) +}) + +test('auto declines past the high-impact limit so the user must choose', () => { + // at the limit it still auto-derives (10% impact, 1% pool -> 16%) + assert.deepEqual(autoSlippage(AUTO_IMPACT_LIMIT_BPS, 100), { bps: 1600, tone: 'red' }) + // beyond it, no guess — the caller forces a manual choice + assert.equal(autoSlippage(AUTO_IMPACT_LIMIT_BPS + 1, 100), null) + assert.equal(autoSlippage(2500, 0), null) +}) + +test('auto slippage tolerates a missing/garbage impact or fee', () => { + assert.deepEqual(autoSlippage(Number.NaN, 100), { bps: 130, tone: 'amber' }) + assert.deepEqual(autoSlippage(-5, 0), { bps: 30, tone: 'green' }) + assert.deepEqual(autoSlippage(0, Number.NaN), { bps: 30, tone: 'green' }) + assert.deepEqual(autoSlippage(0, -40), { bps: 30, tone: 'green' }) +}) + +test('slippage bps clamps to the sane band', () => { + assert.equal(clampSlippageBps(5), MIN_SLIPPAGE_BPS) + assert.equal(clampSlippageBps(99_999), MAX_SLIPPAGE_BPS) + assert.equal(clampSlippageBps(250), 250) + assert.equal(clampSlippageBps(Number.NaN), MIN_SLIPPAGE_BPS) +}) + +test('retry floor after a slippage halt: 1.5x, ceil to 0.1%, clamped', () => { + assert.equal(retrySlippage(100), 150) // 1% -> 1.5% + assert.equal(retrySlippage(150), 230) // 1.5% -> 2.25% -> ceil 2.3% + assert.equal(retrySlippage(10), 20) // floor value still moves up + assert.equal(retrySlippage(4000), MAX_SLIPPAGE_BPS) // never past the 50% ceiling + assert.equal(retrySlippage(0), MIN_SLIPPAGE_BPS) +}) + +test('typed slippage percent parses to clamped bps or null', () => { + assert.equal(slippagePctToBps('2.5'), 250) + assert.equal(slippagePctToBps('0'), null) + assert.equal(slippagePctToBps(''), null) + assert.equal(slippagePctToBps('abc'), null) + assert.equal(slippagePctToBps('100'), MAX_SLIPPAGE_BPS) // 100% → clamps to 50% +}) + +// Live capture, WETH->CASHCAT 0.001 at block 14620232, both replayed on a fork +// pinned to it: the solver DELIVERED 26.66 bps more than the best direct route +// while its per-route cost number read worse. These are those exact wei. +const SOLVER_OUT = 27824940167729483416n +const DIRECT_OUT = 27750957768818896557n +const SOLVER_IMPACT_BPS = 20 +const DIRECT_POOL_FEE_BPS = 30 + +// A baseline above both outputs — its level cancels out of the comparison, +// which is the entire point of sharing one. +const MID_OUT = 27_900_000_000_000_000_000n + +test('the shared baseline ranks routes the way their outputs do', () => { + const solverCost = allInCostBps(SOLVER_OUT, MID_OUT) as number + const directCost = allInCostBps(DIRECT_OUT, MID_OUT) as number + // the solver row must read CHEAPER, because it delivers more + assert.ok(solverCost < directCost) + // and the gap must match the measured on-chain delivery gap (+26.66 bps) + const measured = Number(((SOLVER_OUT - DIRECT_OUT) * 10_000n) / DIRECT_OUT) + assert.ok(Math.abs(directCost - solverCost - measured) <= 1) +}) + +test('the per-route cost ranked those same two routes backwards', () => { + // why one shared baseline exists. The shipped comparison summed each row's + // own impact with its own pool fee: 20 + 35.5 blended for the solver against + // 0 + 30 for the single direct pool. + const solverOld = SOLVER_IMPACT_BPS + 35.5 + const directOld = 0 + DIRECT_POOL_FEE_BPS + assert.ok(solverOld > directOld) // the better route looked worse + // against one denominator the same two rows order by delivered output + assert.ok((allInCostBps(SOLVER_OUT, MID_OUT) as number) < (allInCostBps(DIRECT_OUT, MID_OUT) as number)) +}) + +// Live capture, WETH->UP 1.0 at block 14669650, straight off the solver. +// The probe routes 100% through the 0.30% v2 pool; full size routes 91% into +// the 1.00% CL pool, which is why the two wrong numbers miss in OPPOSITE +// directions and neither can be nudged into the other. +const UP_NET_OUT = 15492887659763651613081n +const UP_MID_OUT = 15631003702256628545100n +const UP_IMPACT_BPS = 59 // the whole headline before this change +const UP_ROUTE_FEE_BPS = 93.48 // share-weighted across the split + +test('the headline is the all-in cost, not the size move and not the naive sum', () => { + const allIn = allInCostBps(UP_NET_OUT, UP_MID_OUT) as number + assert.equal(allIn, 88) // 88.36, to the display's resolution + + // it must sit STRICTLY between the two numbers that shipped: the bare size + // move understates by the probe route's own 30 bps fee, and adding the full + // route's fee back overstates by counting the 0.30%->1.00% gap twice + const naiveSum = UP_IMPACT_BPS + UP_ROUTE_FEE_BPS + assert.ok(UP_IMPACT_BPS < allIn && allIn < naiveSum) + assert.ok(Math.abs(naiveSum - 152.48) < 1e-9) // fractional venue fee, float noise +}) + +test('the cost is unavailable for a missing or stale baseline', () => { + assert.equal(allInCostBps(SOLVER_OUT * 2n, MID_OUT), null) + assert.equal(allInCostBps(MID_OUT, MID_OUT), 0) // free size and free fees + assert.equal(allInCostBps(SOLVER_OUT, null), null) +}) + +test('autoSlippage takes the RAW size move, never the all-in cost', () => { + // it adds the pool fee itself, so handing it a fee-inclusive number counts + // that fee twice — this pins the two apart + const impact = 40 + const poolFee = 30 + const raw = autoSlippage(impact, poolFee) + const doubled = autoSlippage(impact + poolFee, poolFee) + assert.notDeepEqual(raw, doubled) + assert.deepEqual(raw, { bps: 100, tone: 'green' }) // 40 + 30 + max(30, 20) + assert.deepEqual(doubled, { bps: 140, tone: 'amber' }) // fee counted twice: 70 + 30 + 35 +}) diff --git a/src/lib/swapGate.ts b/src/lib/swapGate.ts new file mode 100644 index 0000000..540f4f8 --- /dev/null +++ b/src/lib/swapGate.ts @@ -0,0 +1,82 @@ +type SlippageTone = 'green' | 'amber' | 'red' +export const SLIPPAGE_CHOICES = [50, 100, 300] as const +export type SlippageBps = (typeof SLIPPAGE_CHOICES)[number] +export type AutoSlippage = { bps: number; tone: SlippageTone } + +// bounds for ANY slippage value — auto-derived, preset, or hand-typed +export const MIN_SLIPPAGE_BPS = 10 // 0.1% floor +export const MAX_SLIPPAGE_BPS = 5000 // 50% fat-finger ceiling + +/** clamp + round an arbitrary bps into the allowed slippage band */ +export function clampSlippageBps(bps: number): number { + if (!Number.isFinite(bps)) return MIN_SLIPPAGE_BPS + return Math.min(MAX_SLIPPAGE_BPS, Math.max(MIN_SLIPPAGE_BPS, Math.round(bps))) +} + +/** a user-typed percent ("2.5") → clamped bps, or null when not a positive number */ +export function slippagePctToBps(pct: string): number | null { + const n = Number(pct.trim()) + if (!Number.isFinite(n) || n <= 0) return null + return clampSlippageBps(Math.round(n * 100)) +} + +/** shared warning tone for any slippage magnitude */ +export function slippageTone(bps: number): SlippageTone { + if (bps <= 100) return 'green' + if (bps <= 300) return 'amber' + return 'red' +} + +// past this measured impact AUTO refuses to guess: the trade is eating so much +// of the pool that a derived tolerance is meaningless (it balloons toward the +// 50% ceiling), so the user must set the number by hand — same forced-choice +// path as an unavailable impact probe. +export const AUTO_IMPACT_LIMIT_BPS = 1000 // 10% + +/** AUTO floor after a slippage-caused halt: 1.5× the tolerance that just + * failed, rounded up to 0.1% — re-offering the number that failed would just + * fail again, while an unbounded jump would be a blank check */ +export function retrySlippage(failedBps: number): number { + return clampSlippageBps(Math.ceil((failedBps * 1.5) / 10) * 10) +} + +/** + * Quote-derived AUTO policy for MARKET + ZAP: the tolerance absorbs price drift + * between quote and execution. Cover the quote's measured impact, plus the + * pool's fee tier as a volatility prior (1% tiers exist because their pairs + * move between blocks; 0.05% tiers barely do — the fee itself is already paid + * inside the quote, it is NOT a cost here), plus a 0.3% pad — or half the + * impact when that is larger. Rounded up to 0.1%, no fixed ceiling inside the + * sane range. Returns null past AUTO_IMPACT_LIMIT_BPS so the caller forces an + * explicit choice. Override anytime via a preset or typed value. + */ +export function autoSlippage(impactBps: number, poolFeeBps: number): AutoSlippage | null { + const safe = Number.isFinite(impactBps) && impactBps > 0 ? impactBps : 0 + if (safe > AUTO_IMPACT_LIMIT_BPS) return null + const fee = Number.isFinite(poolFeeBps) && poolFeeBps > 0 ? poolFeeBps : 0 + const raw = safe + fee + Math.max(30, safe * 0.5) // impact + pool fee + max(0.3%, half the impact) + const bps = clampSlippageBps(Math.ceil(raw / 10) * 10) + return { bps, tone: slippageTone(bps) } +} + +/** + * A quote's all-in cost in bps — price move, pool fees, modeled transfer taxes + * and terminal fee at once. `netOut` is what the user actually receives; + * `midOut` is the fee-free price for this size, so everything execution took is + * the gap between them and nothing has to be summed (or double-counted). + * + * Every row on screen divides by the SAME `midOut`, whichever quote source + * produced it, so the ordering matches delivered output by construction and the + * cost can never contradict the "behind best" chip beside it. Scoring each row + * against its own probe did exactly that: measured on-chain, the solver + * delivered +26.66 bps more CASHCAT than the best direct route while its card + * read 0.55% against that row's 0.30%, ranking the two backwards. + * + * A missing baseline, or one below the delivered output because the quotes are + * out of sync, makes the cost unavailable. + */ +export function allInCostBps(netOut: bigint, midOut: bigint | null): number | null { + if (midOut === null) return null + if (netOut > midOut) return null + return Number(((midOut - netOut) * 10_000n + midOut / 2n) / midOut) // nearest, not floor +} diff --git a/src/lib/swapSubmissions.test.ts b/src/lib/swapSubmissions.test.ts new file mode 100644 index 0000000..1b0d417 --- /dev/null +++ b/src/lib/swapSubmissions.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import type { Address } from 'viem' +import { swapSubmissionKey, swapSubmissions } from './swapSubmissions' + +test('swap submissions stay locked outside a mounted component', async () => { + const account = '0x1111111111111111111111111111111111111111' as Address + const tokenIn = '0x2222222222222222222222222222222222222222' as Address + const tokenOut = '0x3333333333333333333333333333333333333333' as Address + const key = swapSubmissionKey(account, tokenIn, tokenOut, 1000n) + let release!: () => void + const wallet = new Promise((resolve) => { + release = resolve + }) + let duplicateRan = false + + const first = swapSubmissions.run(key, () => wallet) + assert.equal(swapSubmissions.has(key), true) + assert.equal( + await swapSubmissions.run(key, async () => { + duplicateRan = true + }), + false, + ) + assert.equal(duplicateRan, false) + + release() + assert.equal(await first, true) + assert.equal(swapSubmissions.has(key), false) + assert.equal(await swapSubmissions.run(key, async () => {}), true) +}) diff --git a/src/lib/swapSubmissions.ts b/src/lib/swapSubmissions.ts new file mode 100644 index 0000000..5870c55 --- /dev/null +++ b/src/lib/swapSubmissions.ts @@ -0,0 +1,47 @@ +import type { Address } from 'viem' + +const active = new Set() +const subscribers = new Set<() => void>() + +export function swapSubmissionKey( + account: Address, + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, +): string { + return `${account.toLowerCase()}:${tokenIn.toLowerCase()}:${tokenOut.toLowerCase()}:${amountIn}` +} + +function notify() { + for (const subscriber of subscribers) { + try { + subscriber() + } catch { + // A render observer must never release the submission lock early. + } + } +} + +export const swapSubmissions = { + has(key: string): boolean { + return active.has(key) + }, + + async run(key: string, task: () => Promise): Promise { + if (active.has(key)) return false + active.add(key) + notify() + try { + await task() + return true + } finally { + active.delete(key) + notify() + } + }, + + subscribe(subscriber: () => void): () => void { + subscribers.add(subscriber) + return () => subscribers.delete(subscriber) + }, +} diff --git a/src/lib/tx.test.ts b/src/lib/tx.test.ts new file mode 100644 index 0000000..9621dbe --- /dev/null +++ b/src/lib/tx.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { mock, test } from 'node:test' +import type { Hex, ReplacementReason, TransactionReceipt } from 'viem' + +type WaitOptions = { + onReplaced?: (event: { + reason: ReplacementReason + replacedTransaction: { hash: Hex } + transaction: { hash: Hex } + }) => void +} + +const hash = (n: number) => `0x${n.toString(16).padStart(64, '0')}` as Hex +const receipt = (transactionHash: Hex): TransactionReceipt => + ({ transactionHash, status: 'success', blockNumber: 1n, logs: [] }) as unknown as TransactionReceipt + +let wait = async (_options: WaitOptions): Promise => receipt(hash(1)) + +mock.module('wagmi/actions', { + namedExports: { + readContract: async () => { + throw new Error('unexpected readContract') + }, + writeContract: async () => { + throw new Error('unexpected writeContract') + }, + waitForTransactionReceipt: async (_config: unknown, options: WaitOptions) => wait(options), + }, +}) +mock.module('../config/wagmi', { namedExports: { wagmiConfig: {} } }) +mock.module('../config/query', { + namedExports: { queryClient: { invalidateQueries: async () => {} } }, +}) +mock.module('../i18n', { namedExports: { t: (key: string) => key } }) + +const { step } = await import('./tx') + +test('step keeps tracking after observer errors and classifies replacements', async () => { + wait = async () => receipt(hash(1)) + const submitted = await step('swap', async () => hash(1), { + onSubmitted: () => { + throw new Error('storage observer failed') + }, + }) + assert.equal(submitted?.transactionHash, hash(1)) + + let replacement: [Hex, Hex, ReplacementReason] | null = null + wait = async (options) => { + options.onReplaced?.({ + reason: 'repriced', + replacedTransaction: { hash: hash(1) }, + transaction: { hash: hash(2) }, + }) + return receipt(hash(2)) + } + const repriced = await step('swap', async () => hash(1), { + onReplaced: (oldHash, newHash, reason) => { + replacement = [oldHash, newHash, reason] + }, + }) + assert.equal(repriced?.transactionHash, hash(2)) + assert.deepEqual(replacement, [hash(1), hash(2), 'repriced']) + + let failure: string | null = null + wait = async (options) => { + options.onReplaced?.({ + reason: 'cancelled', + replacedTransaction: { hash: hash(3) }, + transaction: { hash: hash(4) }, + }) + return receipt(hash(4)) + } + const cancelled = await step('swap', async () => hash(3), { + onFail: (why) => { + failure = why + }, + }) + assert.equal(cancelled, null) + assert.equal(failure, 'rejected') +}) diff --git a/src/lib/tx.ts b/src/lib/tx.ts index b99169a..9032e52 100644 --- a/src/lib/tx.ts +++ b/src/lib/tx.ts @@ -1,20 +1,27 @@ -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 { + BaseError, + erc20Abi, + parseAbi, + parseEventLogs, + UserRejectedRequestError, + type Address, + type Hex, + type ReplacementReason, + type TransactionReceipt, +} from 'viem' +import { readContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { ADDR, CHAIN_ID, NATIVE } from '../config/addresses' import { queryClient } from '../config/query' -import { ADDR, CHAIN_ID } from '../config/addresses' +import { wagmiConfig, type ConfiguredChainId } from '../config/wagmi' 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')], + [/Return amount is not enough/i, () => t('tx.hintSwapMinOut')], [/reason:\s*PS\b/, () => t('tx.hintPS')], [/INSUFFICIENT_[AB]_AMOUNT/, () => t('tx.hintV2Ratio')], [/reason:\s*STF\b/, () => t('tx.hintSTF')], @@ -22,7 +29,7 @@ const REVERT_HINTS: [RegExp, () => string][] = [ [/Transaction too old/i, () => t('tx.hintDeadline')], ] -function shortErr(e: unknown): string { +export 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] @@ -53,37 +60,109 @@ export function invalidateAll() { void queryClient.invalidateQueries() } +/** why a step returned null — lets multi-step flows tailor their advice + * (an on-chain revert is actionable; a wallet rejection is a user choice) */ +export type StepFailWhy = 'rejected' | 'reverted' | 'error' + +function stepFailWhy(e: unknown): StepFailWhy { + if (e instanceof BaseError && e.walk((x) => x instanceof UserRejectedRequestError)) return 'rejected' + return /reject|denied|declin/i.test(String((e as Error)?.message ?? '')) ? 'rejected' : 'error' +} + /** * 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. + * Returns the confirmed receipt on success, null on rejection/revert (callers + * should stop a multi-step flow on null; opts.onFail hears which kind of null). + * opts.onSuccess sees the same receipt. opts.chainId/explorer let bridge steps + * run on a remote origin chain — every other flow defaults to Robinhood. */ export async function step( label: string, send: () => Promise, - opts?: { onSuccess?: (rcpt: TransactionReceipt) => void }, -): Promise { + opts?: { + /** fires the instant the tx is broadcast (hash in hand), before it confirms + * — lets a caller persist a prominent "pending" entry that survives reload */ + onSubmitted?: (hash: Hex) => void + onReplaced?: (oldHash: Hex, newHash: Hex, reason: ReplacementReason) => void + onSuccess?: (rcpt: TransactionReceipt) => void + onFail?: (why: StepFailWhy) => void + chainId?: ConfiguredChainId + explorer?: string + }, +): Promise { const id = txlog.push('pending', t('tx.confirm', { label })) + const chainId = opts?.chainId ?? CHAIN_ID + const href = (hash: Hex) => (opts?.explorer ? `${opts.explorer}/tx/${hash}` : undefined) + let activeHash: Hex | undefined 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 }) + activeHash = hash + txlog.update(id, { text: t('tx.pending', { label }), hash, href: href(hash) }) + try { + opts?.onSubmitted?.(hash) + } catch (e) { + txlog.push('err', `${label} — ${shortErr(e)}`, hash) + } + let replacementReason: ReplacementReason | null = null + const rcpt = await waitForTransactionReceipt(wagmiConfig, { + hash, + chainId, + // Robinhood blocks in ~100ms; viem's 4s default would leave a confirmed + // swap looking pending for seconds (the "is it stuck?" reload trigger). + // Remote bridge chains keep the default cadence. + pollingInterval: chainId === CHAIN_ID ? 800 : undefined, + onReplaced: ({ reason, replacedTransaction, transaction }) => { + const oldHash = replacedTransaction.hash + activeHash = transaction.hash + replacementReason = reason + txlog.update(id, { hash: activeHash, href: href(activeHash) }) + try { + opts?.onReplaced?.(oldHash, activeHash, reason) + } catch (e) { + txlog.push('err', `${label} — ${shortErr(e)}`, activeHash) + } + }, + }) + activeHash = rcpt.transactionHash + if (replacementReason && replacementReason !== 'repriced') { + txlog.update(id, { + kind: 'err', + text: `${label} — ${replacementReason}`, + hash: activeHash, + href: href(activeHash), + }) invalidateAll() + opts?.onFail?.(replacementReason === 'cancelled' ? 'rejected' : 'error') return null } - txlog.update(id, { kind: 'ok', text: t('tx.ok', { label, n: rcpt.blockNumber.toString() }), hash }) + if (rcpt.status !== 'success') { + txlog.update(id, { kind: 'err', text: t('tx.reverted', { label }), hash: activeHash, href: href(activeHash) }) + invalidateAll() + opts?.onFail?.('reverted') + return null + } + txlog.update(id, { + kind: 'ok', + text: t('tx.ok', { label, n: rcpt.blockNumber.toString() }), + hash: activeHash, + href: href(activeHash), + }) invalidateAll() try { opts?.onSuccess?.(rcpt) - } catch { - /* follow-up is best-effort */ + } catch (e) { + txlog.push('err', `${label} — ${shortErr(e)}`, activeHash) } - return hash + return rcpt } catch (e) { - txlog.update(id, { kind: 'err', text: `${label} — ${shortErr(e)}` }) + txlog.update(id, { + kind: 'err', + text: `${label} — ${shortErr(e)}`, + hash: activeHash, + href: activeHash ? href(activeHash) : undefined, + }) invalidateAll() + opts?.onFail?.(stepFailWhy(e)) return null } } @@ -120,6 +199,8 @@ export function offerSwapClaimedUp(user: Address) { } } +export type AllowanceResult = 'sufficient' | 'approved' + /** Approve `spender` for exactly `amount` if current allowance is lower. */ export async function ensureAllowance( token: Address, @@ -127,7 +208,7 @@ export async function ensureAllowance( spender: Address, amount: bigint, symbol: string, -): Promise { +): Promise { const current = await readContract(wagmiConfig, { abi: erc20Abi, address: token, @@ -135,9 +216,10 @@ export async function ensureAllowance( args: [owner, spender], chainId: CHAIN_ID, }) - if (current >= amount) return true + if (current >= amount) return 'sufficient' const h = await step(t('tx.approve', { sym: symbol }), () => writeContract(wagmiConfig, { + account: owner, abi: erc20Abi, address: token, functionName: 'approve', @@ -145,7 +227,7 @@ export async function ensureAllowance( chainId: CHAIN_ID, }), ) - return h !== null + return h ? 'approved' : null } export function deadline(secondsFromNow = 1200): bigint { diff --git a/src/lib/txlog.ts b/src/lib/txlog.ts index a7e7c8c..71359bb 100644 --- a/src/lib/txlog.ts +++ b/src/lib/txlog.ts @@ -8,6 +8,9 @@ export type LogLine = { kind: LogKind text: string hash?: string + /** full explorer url for hash — defaults to the Robinhood explorer when unset + * (bridge steps on remote chains carry their own) */ + href?: string action?: LogAction } diff --git a/src/lib/uniBrowse.ts b/src/lib/uniBrowse.ts index 4a7303e..3e8b8e8 100644 --- a/src/lib/uniBrowse.ts +++ b/src/lib/uniBrowse.ts @@ -6,8 +6,9 @@ // 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: +// So discovery is token-centric via DexScreener (browser-direct by default; +// same-origin proxied only in KYBERSWAP_AGGREGATOR_API_BASE_URL=/kyber +// builds), 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' diff --git a/src/lib/v2Fees.test.ts b/src/lib/v2Fees.test.ts new file mode 100644 index 0000000..1bf5d72 --- /dev/null +++ b/src/lib/v2Fees.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { Address, PublicClient } from 'viem' +import { previewV2ClaimFees } from './v2Fees' + +const pool = '0x0000000000000000000000000000000000000001' as Address +const user = '0x0000000000000000000000000000000000000002' as Address + +test('previews materialized V2 fees as the LP owner', async () => { + let request: Record | undefined + const client = { + simulateContract: async (next: Record) => { + request = next + return { result: [11n, 22n] as const } + }, + } as unknown as PublicClient + + assert.deepEqual(await previewV2ClaimFees(client, pool, user, [0n, 0n]), [11n, 22n]) + assert.equal(request?.address, pool) + assert.equal(request?.functionName, 'claimFees') + assert.equal(request?.account, user) +}) + +test('keeps getter fees when the V2 preview fails', async () => { + const client = { + simulateContract: async () => { + throw new Error('rpc unavailable') + }, + } as unknown as PublicClient + + assert.deepEqual(await previewV2ClaimFees(client, pool, user, [3n, 4n]), [3n, 4n]) +}) + +test('trusts a successful zero V2 preview over stale getters', async () => { + const client = { + simulateContract: async () => ({ result: [0n, 0n] as const }), + } as unknown as PublicClient + + assert.deepEqual(await previewV2ClaimFees(client, pool, user, [3n, 4n]), [0n, 0n]) +}) diff --git a/src/lib/v2Fees.ts b/src/lib/v2Fees.ts new file mode 100644 index 0000000..4b34566 --- /dev/null +++ b/src/lib/v2Fees.ts @@ -0,0 +1,21 @@ +import type { Address, PublicClient } from 'viem' +import { v2PoolAbi } from '../abi' + +export async function previewV2ClaimFees( + pc: PublicClient, + pool: Address, + user: Address, + fallback: readonly [bigint, bigint], +): Promise { + try { + const sim = await pc.simulateContract({ + abi: v2PoolAbi, + address: pool, + functionName: 'claimFees', + account: user, + }) + return sim.result as readonly [bigint, bigint] + } catch { + return fallback + } +} diff --git a/src/lib/zap.ts b/src/lib/zap.ts index 96d5d9f..46e38d8 100644 --- a/src/lib/zap.ts +++ b/src/lib/zap.ts @@ -1,9 +1,9 @@ // 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). +// the two piles match the deposit ratio the target needs, swap through the +// best direct Uniswap/UP33 route, 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 @@ -13,11 +13,11 @@ // 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 type { Address, PublicClient, TransactionReceipt } from 'viem' +import { readContract, 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 { ADDR, CHAIN_ID, NATIVE, UNI } from '../config/addresses' +import { zapFee } from '../config/env' import { wagmiConfig } from '../config/wagmi' import { t } from '../i18n' import { @@ -28,52 +28,74 @@ import { minAmountsForLiquidity, Q96, } from './clmath' +import { + directRouteLabel, + isNative, + quoteDirectCandidates, + type DirectCandidate, + type DirectQuotes, + type DirectRoute, +} from './directSwap' import { fmtAmount } from './format' -import { kyberRoute, NATIVE, type KyberRouteData } from './kyber' -import { buildGatedKyberTx } from './kyberExec' -import { deadline, ensureAllowance, fetchSqrtPriceX96, receivedOf, step } from './tx' +import { fetchSolverQuote, solverRouteLabel, solverVenueFeeBps } from './solver' +import { stakeClNft, stakeV2Lp, mintedTokenId } from './stake' +import { executeSolverSwap, executeSwap, SlippageError } from './swapExec' +import { solveSwap, splitOutside } from './zapMath' +import { deadline, ensureAllowance, fetchSqrtPriceX96, receivedOf, step, shortErr, type StepFailWhy } 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 +/** deposit mins: 1% band-edge, same as the PAIR flows (panel copy quotes it) */ +export const DEPOSIT_MINS_BPS = 100 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 } +/** the swap leg's venue: SHEEP CHOICE (solver-routed, fee charged server-side + * inside the Settler tx) or a direct route (fee via the router's *WithFee) */ +export type ZapSwapVia = + | { via: 'direct'; route: DirectRoute } + | { via: 'solver'; routeLabel: string; venueFeeBps: number } + +/** one planned swap: a slice of the input bought into a pool side */ +export type ZapLeg = { + /** true → buys pool.token0; false → pool.token1 */ + buyIs0: boolean + swapIn: bigint + via: ZapSwapVia + estOut: bigint + impactBps: number | null +} + export type ZapPlan = { /** what the user selected (NATIVE sentinel allowed) */ tokenIn: Address nativeIn: boolean /** erc20 actually spent (WETH when nativeIn) */ erc20In: Address - inIs0: boolean + /** which pool side the input IS — null when it's an outside token + * (WETH/USDG/anything), in which case BOTH sides come from swap legs */ + inIs0: boolean | null amountIn: bigint - swapIn: bigint + /** input deposited as-is, no swap (pool-side input only; 0 otherwise) */ keep: bigint - route: KyberRouteData | null // final planning quote (null = no swap needed) - estOut: bigint + /** swap legs acquiring whatever the input doesn't cover (0–2) */ + legs: ZapLeg[] 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 + /** worst leg's size impact versus a small executable quote; terminal fee excluded */ + impactBps: number | null } -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) +function needAndSpot(target: ZapTarget, inIs0: boolean): { rho: number; spot: number } { + const pool = target.pool if (pool.kind === 'v2') { const r0 = Number(pool.reserve0) const r1 = Number(pool.reserve1) @@ -81,7 +103,7 @@ function needAndSpot(tgt: ZapTarget, inIs0: boolean): { rho: number; spot: numbe const rho = inIs0 ? r1 / r0 : r0 / r1 return { rho, spot: rho } // marginal v2 price == reserve ratio } - const { tickLower, tickUpper } = tgt as Extract + const { tickLower, tickUpper } = target as Extract const p = Number(pool.sqrtPriceX96) / Number(Q96) const a = Number(getSqrtRatioAtTick(tickLower)) / Number(Q96) const b = Number(getSqrtRatioAtTick(tickUpper)) / Number(Q96) @@ -96,60 +118,155 @@ function needAndSpot(tgt: ZapTarget, inIs0: boolean): { rho: number; spot: numbe 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 +/** the target's raw-unit deposit ratio amt1/amt0 at the current price + * (0 → token0 only, ∞ → token1 only) + spot as token1-per-token0 — + * needAndSpot with inIs0=true measures exactly this */ +function needRatio1Per0(target: ZapTarget): { rho: number; spot: number } { + return needAndSpot(target, true) +} + +/** + * Best executable quote across venues. A venue whose quote *failed* (reverted / + * RPC error) only blocks planning when it leaves us with nothing to route + * through — a dead pool on one venue must never veto a zap another venue can + * serve. Throws the comparison error only when NO venue produced a usable quote + * yet at least one failed (so the reason is "quoting broke", not "no pool"). + */ +function bestExecutableQuote(quotes: DirectQuotes): DirectCandidate | null { + if (quotes.best) return quotes.best + const failed = (['uniswap', 'up33'] as const).filter((protocol) => quotes.status[protocol] === 'failed') + if (failed.length > 0) throw new Error(t('zap.errQuoteFailed', { protocols: failed.join(' / ') })) + return null } /** * 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. + * (no route, empty pool, dust amounts). Network: 1–2 direct-route quote rounds. */ +/** display label for a planned swap leg */ +export function zapRouteLabel(swap: ZapSwapVia): string { + return swap.via === 'direct' ? directRouteLabel(swap.route) : swap.routeLabel +} + +/** worst swap-leg venue fee in bps — autoSlippage's volatility prior (0 = no swap) */ +export function zapVenueFeeBps(plan: ZapPlan): number { + let worst = 0 + for (const leg of plan.legs) { + const fee = leg.via.via === 'direct' ? leg.via.route.feePpm / 100 : leg.via.venueFeeBps + if (fee > worst) worst = fee + } + return worst +} + +/** one planning-time quote for the swap leg, whichever venue answered best */ +type SwapLegQuote = { via: ZapSwapVia; amountOut: bigint; impactBps: number | null } + +/** + * Quote the swap leg across BOTH kinds: the solver (SHEEP CHOICE — zap's fee + * charged server-side via the request's feeBps override) and the direct + * venues (fee inside the client-side quote). Both amounts are net of the + * terminal fee, so the comparison is apples-to-apples; the solver wins ties + * like the SWAP tab. One side failing alone is fine — a total blank rethrows + * the direct path's (localized) error. + */ +async function quoteSwapLeg( + client: PublicClient, + pools: readonly Pool[] | null, + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, + feeBps: number, +): Promise { + const [direct, solver] = await Promise.allSettled([ + quoteDirectCandidates(client, pools, tokenIn, tokenOut, amountIn, feeBps).then(bestExecutableQuote), + fetchSolverQuote({ tokenIn, tokenOut, amountIn, slippageBps: 50, feeBps }), + ]) + const best = direct.status === 'fulfilled' ? direct.value : null + const routed = solver.status === 'fulfilled' ? solver.value : null + if (routed && (!best || routed.amountOutNet >= best.amountOut)) { + return { + via: { + via: 'solver', + routeLabel: `${t('swap.solverRoute')} · ${solverRouteLabel(routed)}`, + venueFeeBps: solverVenueFeeBps(routed), + }, + amountOut: routed.amountOutNet, + impactBps: routed.priceImpactBps, + } + } + if (best) return { via: { via: 'direct', route: best.route }, amountOut: best.amountOut, impactBps: best.impactBps } + if (direct.status === 'rejected') throw direct.reason + throw new Error(t('zap.errNoRoute')) +} + export async function planZap(args: { + client: PublicClient + pools: readonly Pool[] | null target: ZapTarget tokenIn: Address // NATIVE | pool.token0 | pool.token1 amountIn: bigint - signal?: AbortSignal }): Promise { - const { target, tokenIn, amountIn } = args - const pool = poolOf(target) + const { client, pools, target, tokenIn, amountIn } = args + const pool = target.pool if (amountIn <= 0n) throw new Error(t('zap.errAmount')) - const nativeIn = isNat(tokenIn) + const nativeIn = isNative(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 lower = erc20In.toLowerCase() + const inIs0 = + lower === pool.token0.toLowerCase() ? true : lower === pool.token1.toLowerCase() ? false : null - const { rho, spot } = needAndSpot(target, inIs0) + const feeBps = zapFee().bps + const quoteLegs = (spec: { buyIs0: boolean; swapIn: bigint }[]): Promise => + Promise.all( + spec.map(async ({ buyIs0, swapIn }) => { + const q = await quoteSwapLeg(client, pools, erc20In, buyIs0 ? pool.token0 : pool.token1, swapIn, feeBps) + if (q.amountOut === 0n) throw new Error(t('zap.errZeroQuote')) + return { buyIs0, swapIn, via: q.via, estOut: q.amountOut, impactBps: q.impactBps } + }), + ) - // --- 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 }) + // --- solve the swap size(s), ≤2 quote rounds across solver + direct venues --- + let keep = 0n + let legs: ZapLeg[] = [] + if (inIs0 !== null) { + // pool-side input: keep a slice, swap the rest into the counter token + const { rho, spot } = needAndSpot(target, inIs0) + let swapIn = solveSwap(amountIn, rho, spot) + 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) { + legs = await quoteLegs([{ buyIs0: !inIs0, swapIn }]) + const q1 = Number(legs[0].estOut) / 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) { + legs = await quoteLegs([{ buyIs0: !inIs0, swapIn: s1 }]) + } + } + keep = amountIn - (legs[0]?.swapIn ?? 0n) + } else { + // outside token: both sides come from swaps, seeded at the pool's own spot + const { rho, spot } = needRatio1Per0(target) + legs = await quoteLegs(splitOutside(amountIn, rho, 1 / spot)) + const l0 = legs.find((l) => l.buyIs0) + const l1 = legs.find((l) => !l.buyIs0) + if (l0 && l1) { + // re-solve from the measured leg rates; re-quote both on >0.4% drift + const r0 = Number(l0.estOut) / Number(l0.swapIn) + const r1 = Number(l1.estOut) / Number(l1.swapIn) + const next = splitOutside(amountIn, rho, r0 / r1) + const s0 = next.find((s) => s.buyIs0)?.swapIn ?? 0n + const drift = s0 > l0.swapIn ? s0 - l0.swapIn : l0.swapIn - s0 + if (drift * 250n > amountIn) legs = await quoteLegs(next) } } - 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 + const legOut = (is0: boolean) => legs.filter((l) => l.buyIs0 === is0).reduce((sum, l) => sum + l.estOut, 0n) + const dep0 = (inIs0 === true ? keep : 0n) + legOut(true) + const dep1 = (inIs0 === false ? keep : 0n) + legOut(false) let liquidity = 0n let dust0 = 0n let dust1 = 0n @@ -171,12 +288,15 @@ export async function planZap(args: { } } - // 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 + // worst leg wins; any leg without a probe forces the explicit-slippage path + let impactBps: number | null = legs.length ? 0 : null + for (const leg of legs) { + if (leg.impactBps === null) { + impactBps = null + break + } + if (impactBps !== null && leg.impactBps > impactBps) impactBps = leg.impactBps + } return { tokenIn, @@ -184,98 +304,132 @@ export async function planZap(args: { erc20In, inIs0, amountIn, - swapIn, keep, - route, - estOut, + legs, dep0, dep1, liquidity, dust0, dust1, impactBps, - routeLabel: route ? routeLabelOf(route) : '', } } -function routeLabelOf(r: KyberRouteData): string { - const names = new Set() - 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 } +type ZapStageKind = 'wrap' | 'swap' | 'approve0' | 'approve1' | 'deposit' | 'stake' +type ZapStage = { kind: ZapStageKind; label: string; leg?: number } -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') +/** a pool that mints stakeable liquidity — up33 with a live gauge */ +export function poolStakeable(pool: Pool): boolean { + return pool.protocol === 'up33' && !!pool.gauge && !!pool.gaugeAlive +} -/** 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 +/** a target whose freshly-minted position can be staked (new mint/add, not an + * increase — increasing an existing NFT/LP changes nothing about its stake) */ +export function stakeableTarget(target: ZapTarget): boolean { + return target.kind !== 'cl-increase' && poolStakeable(target.pool) +} + +function depositVerb(target: ZapTarget): string { + switch (target.kind) { + case 'cl-increase': + return t('zap.stIncrease', { id: target.tokenId.toString() }) + case 'cl-mint': + return t('zap.stMint') + case 'v2': + return t('zap.stAddLiquidity') + } +} + +function depositSpender(target: ZapTarget): Address { + if (target.kind === 'v2') { + return target.pool.protocol === 'univ2' ? UNI.V2_ROUTER : ADDR.V2_ROUTER + } + if (target.kind === 'cl-increase') return target.npm + return target.pool.protocol === 'univ3' ? UNI.V3_NPM : ADDR.CL_PM +} + +/** logical execution sequence for preview + progress UI. `stake` appends the + * gauge-stake follow-up when the caller opted in AND the target is stakeable. */ +export function zapStages( + plan: ZapPlan, + target: ZapTarget, + tIn: TokenInfo, + t0: TokenInfo, + t1: TokenInfo, + stake = false, +): ZapStage[] { 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 }) }) + plan.legs.forEach((leg, i) => { + const tOut = leg.buyIs0 ? t0 : t1 stages.push({ kind: 'swap', + leg: i, label: t('zap.stSwap', { - amt: fmtAmount(plan.swapIn, tIn.decimals), + amt: fmtAmount(leg.swapIn, tIn.decimals), sym: tIn.symbol, - out: fmtAmount(plan.estOut, tOut.decimals), + out: fmtAmount(leg.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) }) + if (stake && stakeableTarget(target)) stages.push({ kind: 'stake', label: t('zap.stStake') }) return stages } // ---------------- executor ---------------- -export type ZapRun = { ok: boolean; failedAt: number | null } +/** why a run halted, coarse enough for the panel to pick its advice: + * 'slippage' — the swap leg died against its min-out (raise slippage); + * 'deposit-moved' — the deposit reverted on its fresh-price mins (just retry); + * 'other' — rejection / structural abort / anything else (txlog has details) */ +export type ZapFailReason = 'slippage' | 'deposit-moved' | 'other' + +type ZapRun = { ok: boolean; failedAt: number | null; reason: ZapFailReason | 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. + * failedAt + reason) 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 + stake: boolean // continue into the gauge-stake follow-up (when target is stakeable) + tIn: TokenInfo // what the user funds with (may be outside the pair) t0: TokenInfo t1: TokenInfo onStage?: (i: number) => void }): Promise { - 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 + const { plan, target, user, slipBps, tIn, t0, t1 } = args + const pool = target.pool + const stages = zapStages(plan, target, tIn, t0, t1, args.stake) let i = 0 - const fail = (): ZapRun => ({ ok: false, failedAt: i }) - const abort = (msg: string): ZapRun => { + let stepWhy: StepFailWhy | null = null + const onFail = (why: StepFailWhy) => { + stepWhy = why + } + const fail = (reason: ZapFailReason = 'other'): ZapRun => ({ ok: false, failedAt: i, reason }) + const abort = (msg: string, reason: ZapFailReason = 'other'): ZapRun => { txlog.push('err', t('zap.halt', { msg })) - return fail() + return fail(reason) } - let actualOut = 0n + const actualOut: [bigint, bigint] = [0n, 0n] + let depositRcpt: TransactionReceipt | null = null for (i = 0; i < stages.length; i++) { args.onStage?.(i) + stepWhy = null const st = stages[i] switch (st.kind) { case 'wrap': { @@ -291,54 +445,50 @@ export async function executeZap(args: { 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 + const leg = st.leg === undefined ? undefined : plan.legs[st.leg] + if (!leg) return abort(t('zap.errNoRoute')) + const legOutToken = leg.buyIs0 ? pool.token0 : pool.token1 + // the wrap stage already turned ETH into WETH — approvals say so + const inputSymbol = plan.nativeIn ? 'WETH' : tIn.symbol + let swap try { - fresh = await kyberRoute(plan.erc20In, otherErc20, plan.swapIn) + swap = + leg.via.via === 'solver' + ? await executeSolverSwap({ + tokenIn: plan.erc20In, + tokenOut: legOutToken, + amountIn: leg.swapIn, + slippageBps: slipBps, + minimumAmountOut: applySlippage(leg.estOut, slipBps), + sender: user, + recipient: user, + inputSymbol, + label: `${st.label} [${t('swap.solverRoute')}]`, + feeBps: zapFee().bps, + onStepFail: onFail, + }) + : await executeSwap({ + route: leg.via.route, + tokenIn: plan.erc20In, + tokenOut: legOutToken, + amountIn: leg.swapIn, + minimumAmountOut: applySlippage(leg.estOut, slipBps), + sender: user, + recipient: user, + inputSymbol, + label: `${st.label} [${directRouteLabel(leg.via.route)}]`, + fee: zapFee(), + onStepFail: onFail, + }) } catch (e) { - return abort(t('zap.haltRequote', { err: (e as Error).message })) + return abort((e as Error).message, e instanceof SlippageError ? 'slippage' : 'other') } - 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 + // a swap that REVERTED on-chain died on its min-out check — same + // slippage remedy as the pre-flight gate; a rejection is not advice-worthy + if (!swap) return fail(stepWhy === 'reverted' ? 'slippage' : 'other') + if (swap.output.kind !== 'erc20') return abort(t('zap.haltTokenOut', { addr: NATIVE })) + actualOut[leg.buyIs0 ? 0 : 1] += swap.output.amount break } case 'approve0': @@ -347,31 +497,41 @@ export async function executeZap(args: { 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 + const spender = depositSpender(target) 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() + try { + depositRcpt = await runDeposit(target, user, dep0, dep1, st.label, t0, t1, onFail) + } catch (e) { + // pre-tx reads (fresh price, router quote) can throw on RPC trouble — + // that must halt visibly, not escape as an unhandled rejection + return abort(shortErr(e)) + } + if (!depositRcpt) return fail(stepWhy === 'reverted' ? 'deposit-moved' : 'other') + break + } + case 'stake': { + // the position is already live: staking is a best-effort follow-up, not + // a zap halt — a failure/rejection here just points the user at POSITIONS + if (depositRcpt && !(await runStake(target, user, depositRcpt, t0, t1))) { + txlog.push('info', t('zap.stakeHint'), undefined, { + label: t('zap.stakeHintAction'), + onClick: () => { + location.hash = 'positions' + }, + }) + } 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') { + // auto-stake off but the pool is stakeable → nudge them to do it from POSITIONS + if (!args.stake && stakeableTarget(target)) { txlog.push('info', t('zap.stakeHint'), undefined, { label: t('zap.stakeHintAction'), onClick: () => { @@ -379,13 +539,35 @@ export async function executeZap(args: { }, }) } - return { ok: true, failedAt: null } + return { ok: true, failedAt: null, reason: 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] +/** stake the just-minted position, reading its id/LP from the deposit receipt */ +async function runStake( + target: ZapTarget, + user: Address, + rcpt: TransactionReceipt, + t0: TokenInfo, + t1: TokenInfo, +): Promise { + const pool = target.pool + if (!pool.gauge) return false + if (target.kind === 'v2') { + const lp = receivedOf(rcpt, pool.address, user) + return stakeV2Lp(pool.address, pool.gauge, lp, user, `${t0.symbol}/${t1.symbol}`) + } + const npm = pool.protocol === 'univ3' ? UNI.V3_NPM : ADDR.CL_PM + const tokenId = mintedTokenId(rcpt, npm, user) + if (tokenId === null) return false + return stakeClNft(pool.gauge, npm, tokenId) +} + +/** post-swap deposit amounts: kept side exact, swapped sides = what ACTUALLY arrived */ +function depositAmounts(plan: ZapPlan, actualOut: readonly [bigint, bigint]): [bigint, bigint] { + return [ + (plan.inIs0 === true ? plan.keep : 0n) + actualOut[0], + (plan.inIs0 === false ? plan.keep : 0n) + actualOut[1], + ] } async function runDeposit( @@ -396,7 +578,8 @@ async function runDeposit( label: string, t0: TokenInfo, t1: TokenInfo, -): Promise { + onFail?: (why: StepFailWhy) => void, +): Promise { if (target.kind === 'v2') { const pool = target.pool if (pool.protocol === 'univ2') { @@ -420,11 +603,12 @@ async function runDeposit( 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()], + args: [pool.token0, pool.token1, d0, d1, applySlippage(d0, DEPOSIT_MINS_BPS), applySlippage(d1, DEPOSIT_MINS_BPS), user, deadline()], chainId: CHAIN_ID, }), + { onFail }, ) - return h !== null + return h } const quote = await readContract(wagmiConfig, { abi: v2RouterAbi, @@ -444,15 +628,16 @@ async function runDeposit( pool.stable, dep0, dep1, - applySlippage(quote[0], MINS_BPS), - applySlippage(quote[1], MINS_BPS), + applySlippage(quote[0], DEPOSIT_MINS_BPS), + applySlippage(quote[1], DEPOSIT_MINS_BPS), user, deadline(), ], chainId: CHAIN_ID, }), + { onFail }, ) - return h !== null + return h } // CL: fresh price + band-edge mins (see minAmountsForLiquidity) — 'PS'-safe @@ -463,9 +648,9 @@ async function runDeposit( const liq = getLiquidityForAmounts(sqrtP, sqrtA, sqrtB, dep0, dep1) if (liq === 0n) { txlog.push('err', t('zap.halt', { msg: t('zap.haltDepositSmall') })) - return false + return null } - const mins = minAmountsForLiquidity(sqrtP, sqrtA, sqrtB, liq, MINS_BPS) + const mins = minAmountsForLiquidity(sqrtP, sqrtA, sqrtB, liq, DEPOSIT_MINS_BPS) if (target.kind === 'cl-increase') { const h = await step(`${label} (${t0.symbol}/${t1.symbol})`, () => @@ -485,8 +670,9 @@ async function runDeposit( ], chainId: CHAIN_ID, }), + { onFail }, ) - return h !== null + return h } const common = { @@ -519,6 +705,7 @@ async function runDeposit( args: [{ ...common, tickSpacing: pool.tickSpacing, sqrtPriceX96: 0n }], chainId: CHAIN_ID, }), + { onFail }, ) - return h !== null + return h } diff --git a/src/lib/zapMath.test.ts b/src/lib/zapMath.test.ts new file mode 100644 index 0000000..a8193f2 --- /dev/null +++ b/src/lib/zapMath.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { solveSwap, splitOutside } from './zapMath' + +const A = 10n ** 18n + +test('solveSwap: single-sided ratios take none or all', () => { + assert.equal(solveSwap(A, 0, 2), 0n) + assert.equal(solveSwap(A, Infinity, 2), A) +}) + +test('solveSwap: swapped slice matches the ratio at the quoted rate', () => { + // rho = 1 counter per kept, rate = 1 → half swaps + assert.equal(solveSwap(A, 1, 1), A / 2n) + // dep_counter / dep_kept = (s·rate)/(A−s) must equal rho + const rho = 3.7 + const rate = 0.82 + const s = solveSwap(A, rho, rate) + const ratio = (Number(s) * rate) / Number(A - s) + assert.ok(Math.abs(ratio - rho) / rho < 1e-9) +}) + +test('splitOutside: single-sided targets buy one side with everything', () => { + assert.deepEqual(splitOutside(A, 0, 1), [{ buyIs0: true, swapIn: A }]) + assert.deepEqual(splitOutside(A, Infinity, 1), [{ buyIs0: false, swapIn: A }]) +}) + +test('splitOutside: slices sum to the input and hit the target ratio', () => { + const rho = 2.5 // want dep1/dep0 = 2.5 + const r0 = 30_000 // token0 per input + const r1 = 12_000 // token1 per input + const spec = splitOutside(A, rho, r0 / r1) + assert.equal(spec.length, 2) + const s0 = spec.find((s) => s.buyIs0)!.swapIn + const s1 = spec.find((s) => !s.buyIs0)!.swapIn + assert.equal(s0 + s1, A) + const dep0 = Number(s0) * r0 + const dep1 = Number(s1) * r1 + assert.ok(Math.abs(dep1 / dep0 - rho) / rho < 1e-9) +}) + +test('splitOutside: equal rates and rho=1 split evenly', () => { + const spec = splitOutside(A, 1, 1) + assert.equal(spec.length, 2) + assert.equal(spec[0].swapIn, A / 2n) + assert.equal(spec[1].swapIn, A / 2n) +}) + +test('splitOutside: sub-ppm slivers fold into the other leg', () => { + // rho so extreme the t0 slice is < A/1e6 → one leg only + const spec = splitOutside(A, 10_000_000, 1) + assert.equal(spec.length, 1) + assert.equal(spec[0].buyIs0, false) + assert.equal(spec[0].swapIn, A) +}) + +test('splitOutside: degenerate rate ratio degrades to one leg, never throws', () => { + for (const bad of [0, NaN, Infinity]) { + const spec = splitOutside(A, 1, bad) + assert.equal(spec.length, 1) + assert.equal(spec[0].swapIn, A) + } +}) diff --git a/src/lib/zapMath.ts b/src/lib/zapMath.ts new file mode 100644 index 0000000..dbb1e2c --- /dev/null +++ b/src/lib/zapMath.ts @@ -0,0 +1,47 @@ +// Pure zap sizing math — no imports, safe under node:test (zap.ts itself +// pulls wagmi/env and only loads in the browser bundle). + +/** pool-side input: how much of A to swap into the counter token so the two + * piles match the deposit ratio ρ (counter-per-kept) at rate (counter-per-input) */ +export 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 +} + +/** + * Outside-token split: slice the input A into s0+s1 so the two swap outputs + * land on the target ratio ρ = amt1/amt0. With leg rates r0 (token0-per-input) + * and r1 (token1-per-input): s1/s0 = ρ·r0/r1, so s0 = A/(1 + ρ·r0/r1). At fair + * prices r1/r0 is the pool's own spot (token1-per-token0), which seeds round + * one without quoting; the planner re-solves from measured leg rates after. + * Sub-ppm slivers fold into the other leg — not worth a transaction. + */ +export function splitOutside( + amountIn: bigint, + rho: number, + r0OverR1: number, +): { buyIs0: boolean; swapIn: bigint }[] { + if (rho === 0) return [{ buyIs0: true, swapIn: amountIn }] + if (!Number.isFinite(rho)) return [{ buyIs0: false, swapIn: amountIn }] + if (!(r0OverR1 > 0) || !Number.isFinite(r0OverR1)) return [{ buyIs0: true, swapIn: amountIn }] + const f = 1 / (1 + rho * r0OverR1) + let s0 = BigInt(Math.round(Number(amountIn) * Math.min(Math.max(f, 0), 1))) + if (s0 > amountIn) s0 = amountIn + let s1 = amountIn - s0 + if (s0 > 0n && s0 * 1_000_000n < amountIn) { + s1 += s0 + s0 = 0n + } + if (s1 > 0n && s1 * 1_000_000n < amountIn) { + s0 += s1 + s1 = 0n + } + const spec: { buyIs0: boolean; swapIn: bigint }[] = [] + if (s0 > 0n) spec.push({ buyIs0: true, swapIn: s0 }) + if (s1 > 0n) spec.push({ buyIs0: false, swapIn: s1 }) + return spec +} diff --git a/src/main.tsx b/src/main.tsx index 95f169e..53211bd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,7 +11,7 @@ import { txlog } from './lib/txlog' // 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 +// (seen live 2026-07-16: a 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).__viemCcipEagerPin = ccipRequest diff --git a/src/styles.css b/src/styles.css index fe8ed81..a97285c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -136,6 +136,11 @@ * { box-sizing: border-box; } html, body, #root { height: 100%; } +/* RainbowKitProvider wraps the app in an unstyled
— without a + height it breaks the % chain and the whole shell collapses to content + height (footer mid-screen, dead space below). Scoped to the #root wrapper + so RainbowKit's body-portaled modals (also data-rk) stay untouched. */ +#root > div[data-rk] { height: 100%; } body { margin: 0; @@ -188,17 +193,18 @@ button, input, select { .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; } +/* direction-aware update flash (Flash component) — semantic, theme-invariant. + deep, saturated green/red held briefly at full so up/down reads at a glance */ +.flash { border-radius: 2px; padding: 0 3px; } +.flash-up { animation: flashUp 1.3s ease-out; } +.flash-down { animation: flashDown 1.3s ease-out; } +.flash-arrow { font-size: 0.85em; margin-left: 3px; font-weight: 700; } @keyframes flashUp { - 0% { background: rgba(80, 220, 90, 0.3); } + 0%, 15% { background: rgba(16, 138, 62, 0.92); } 100% { background: transparent; } } @keyframes flashDown { - 0% { background: rgba(255, 92, 92, 0.32); } + 0%, 15% { background: rgba(190, 34, 34, 0.92); } 100% { background: transparent; } } @@ -220,6 +226,55 @@ button, input, select { .hdr-meta b { color: var(--fg); font-weight: 400; } /* ---------- generic ---------- */ +/* a term that explains itself on hover. The dashed rule is the ONLY signal an + explanation is there — an unmarked one is undiscoverable. Underline (not + border-bottom) so it follows wrapped text and adds no layout; the rule stays + neutral even under tone-coloured numbers, because the dash is chrome, not + semantics. + We draw the panel instead of using a native `title`: the OS tooltip waits + about a second before appearing, renders 58 characters of Chinese as one + unreadable strip, and never appears at all on a touch screen. */ +.hint { + text-decoration: underline dashed; + text-decoration-thickness: 1px; + text-decoration-color: var(--dim); + text-underline-offset: 3px; + cursor: help; +} +/* the panel hangs off the ROW, not off the inline term: anchored to a term + sitting mid-row it would run past the right edge on a phone. :has() keeps + the containing block off every other .kv/.l2 in the app. */ +.kv:has(.hint), +.l2:has(.hint) { position: relative; } +/* [data-tip] guard: a .hint without one would otherwise hover an empty box */ +.hint[data-tip]::after { + content: attr(data-tip); + position: absolute; + z-index: 70; + top: calc(100% + 7px); + left: 0; + width: max-content; + max-width: min(340px, 100%); + background: var(--inset); + border: 1px solid var(--acc-dim); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7); + padding: 8px 10px; + color: var(--dim); + font-size: 0.8rem; + font-weight: 400; + line-height: 1.55; + letter-spacing: 0; + text-align: left; + text-transform: none; + text-decoration: none; + white-space: normal; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 90ms linear; +} +.hint[data-tip]:hover::after { opacity: 1; visibility: visible; } + .btn { background: var(--panel2); border: 1px solid var(--line-hi); @@ -234,6 +289,8 @@ button, input, select { .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.amber { background: var(--amber); border-color: var(--amber); color: #000; } +.btn.amber:hover:not(:disabled) { background: transparent; color: var(--amber); } .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; } @@ -265,6 +322,30 @@ button, input, select { .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); } +/* jump-off to the pair's DexScreener chart, sat right of the protocol mark. + Badge geometry at row scale: it has to read as a button without out-weighing + the pair name beside it. Spacing comes from the flex parent's gap in all + three homes (.pair-line, .card-head, .pg-title), never a margin here. + `a:hover` underlines every link in this file, hence the second selector. */ +.ds-btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 0 4px; + border: 1px solid var(--line); + color: var(--dim); + font-size: 0.8em; + font-weight: 400; + letter-spacing: 0.5px; + line-height: 1.5; + white-space: nowrap; +} +/* the brand mark keeps its own colors (it is artwork, not an icon font), so it + does not brighten with the label on hover — same as .badge.proto's UP icon */ +.ds-btn img { height: 11px; width: 11px; display: block; } +.ds-btn, +.ds-btn:hover { text-decoration: none; } +.ds-btn:hover { color: var(--fg); border-color: var(--line-hi); background: var(--panel2); } .input { background: var(--inset); @@ -276,9 +357,11 @@ button, input, select { width: 100%; } .input:focus { border-color: var(--acc-dim); } +.input.invalid { border-color: var(--red); } /* wins over :focus by source order */ .input::placeholder { color: var(--placeholder); } .dim { color: var(--dim); } +.fg { color: var(--fg); } .green { color: var(--green); } .amber { color: var(--amber); } .red { color: var(--red); } @@ -286,6 +369,21 @@ button, input, select { .right { text-align: right; } .mono-sm { font-size: 0.88em; } +/* remembered "stake after adding" checkbox, shown on stakeable add surfaces */ +.stake-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--dim); + cursor: pointer; + white-space: nowrap; + user-select: none; +} +.stake-toggle:not(.off):hover { color: var(--fg); } +.stake-toggle input { accent-color: var(--acc); cursor: pointer; margin: 0; } +.stake-toggle.off { cursor: default; opacity: 0.55; } +.stake-toggle.off input { cursor: default; } + /* ---------- main ---------- */ .main { flex: 1; @@ -321,7 +419,11 @@ button, input, select { letter-spacing: 1px; margin: 14px 0 6px; font-size: 0.9em; + display: flex; + align-items: baseline; + gap: 6px; } +.section-title .aux { margin-left: auto; letter-spacing: 0; min-width: 7ch; text-align: center; } .section-title::before { content: '┌─ '; color: var(--line-hi); } .section-title::after { content: ' ─'; color: var(--line-hi); } @@ -339,7 +441,12 @@ table.tbl { width: 100%; border-collapse: collapse; } white-space: nowrap; } .tbl td { padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; } +/* the whole row toggles the add-LP expander (PoolsTab) — the ADD LP button + stays as the keyboard path, so the row itself takes no tab stop */ +.tbl tr.rowhover { cursor: pointer; } .tbl tr.rowhover:hover td { background: var(--panel2); } +/* keep the expanded row visibly attached to the row that opened it */ +.tbl tr.rowhover.is-open td { background: var(--panel2); } .tbl .num { text-align: right; white-space: nowrap; } /* long catalogs: the table scrolls under a sticky header row */ @@ -373,6 +480,28 @@ table.tbl { width: 100%; border-collapse: collapse; } .kv { display: flex; gap: 18px; flex-wrap: wrap; margin: 4px 0; } .kv .k { color: var(--dim); margin-right: 6px; } +/* position metrics readout — label-over-value cells packed left at content + width, one vocabulary for card bodies AND pool-group headers. Each cell + answers one question (worth? / harvestable? / earning?); token breakdowns + stack as dim sub-lines instead of running on in prose */ +.pmetrics { + display: flex; + flex-wrap: wrap; + gap: 10px 32px; + margin: 8px 0 4px; +} +.pmetrics .pcell .k { + display: block; + color: var(--dim); + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 0.8em; + margin-bottom: 2px; +} +.pmetrics .pcell .v { font-size: 1.1em; } +.pmetrics .pcell .sub { display: block; color: var(--dim); margin-top: 1px; } +.pmetrics.compact { gap: 6px 24px; margin: 6px 0 0; } +.pmetrics.compact .pcell .v { font-size: 1em; } .grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 10px; } @@ -383,6 +512,15 @@ table.tbl { width: 100%; border-collapse: collapse; } /* ---------- range bar ---------- */ .rbar-wrap { margin: 8px 0 4px; } +/* which single token you hold once price leaves each end of the band, sat + above the track ends; the side you're currently resting in lights up */ +.rbar-holds { + display: flex; + justify-content: space-between; + gap: 8px; + margin: 0 0 3px; + letter-spacing: 0.3px; +} .rbar { display: flex; align-items: center; @@ -504,22 +642,82 @@ table.tbl { width: 100%; border-collapse: collapse; } /* ---------- 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; +/* market view: header row shares the card column's width so edges line up */ +.swap-box.narrow { max-width: 560px; } +/* static network indicator (single-chain terminal — no selector) */ +.chain-tag { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line-hi); + background: var(--panel2); + padding: 3px 10px; + font-size: 0.8em; + letter-spacing: 1px; + text-transform: uppercase; + white-space: nowrap; + cursor: default; +} +.chain-tag .dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #ccff00; /* Robinhood neon */ + box-shadow: 0 0 6px rgba(204, 255, 0, 0.55); +} +/* the trade column itself is narrower than the panel — focused, Robinhood-style */ +.swap-col { max-width: 560px; margin: 0 auto; } + +/* selectable route cards: line 1 = venue + output + BEST/shortfall, line 2 = pool detail + usd */ +.quote-card { border: 1px solid var(--line); - padding: 7px 10px; + padding: 7px 12px; margin-bottom: 6px; cursor: pointer; +} +.quote-card:hover { border-color: var(--line-hi); } +.quote-card.sel { border-color: var(--acc-dim); background: var(--panel2); } +.quote-card .l1 { display: flex; align-items: baseline; gap: 10px; } +.quote-card .src { text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; } +.quote-card .qstate { margin-left: auto; } +.quote-card .qamt { margin-left: auto; font-size: 1.05em; white-space: nowrap; } +.quote-card .l2 { + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--dim); + font-size: 0.85em; + margin-top: 3px; + padding-left: 18px; /* aligns under the venue name, past the ◉ marker */ 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; } + +/* sheep-choice route visualization: a split-proportion bar + one chip per + leg. Accent shades only — venue identity lives in the mono labels, so the + bar stays legible in every theme (the palette keeps hues semantic). */ +.route-viz { + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 6px; + padding-left: 18px; /* same gutter as .l2 */ +} +.route-bar { display: flex; height: 4px; gap: 1px; } +.route-bar i { flex: 0 0 auto; min-width: 3px; background: var(--acc); } +.route-bar i:nth-child(2) { opacity: 0.68; } +.route-bar i:nth-child(3) { opacity: 0.5; } +.route-bar i:nth-child(4) { opacity: 0.38; } +.route-bar i:nth-child(n + 5) { opacity: 0.26; } +.route-legs { display: flex; flex-wrap: wrap; gap: 4px 6px; font-size: 0.8em; } +.route-legs .leg { + border: 1px solid var(--line); + padding: 1px 6px; + color: var(--dim); + white-space: nowrap; +} +.route-legs .leg b { color: var(--fg); font-weight: 500; } +.route-legs .leg:first-child { border-color: var(--acc-dim); } /* token select */ .tsel { position: relative; } @@ -574,6 +772,219 @@ table.tbl { width: 100%; border-collapse: collapse; } .logline.err .txt { color: var(--red); } .logline.pending .txt { color: var(--amber); } +/* activity log, relocated to a header button (was a footer panel) */ +.hist { position: relative; } +.hist-btn { + background: none; + border: 1px solid var(--line); + color: var(--dim); + padding: 4px 10px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} +.hist-btn:hover { color: var(--fg); border-color: var(--line-hi); } +.hist-btn.on { color: var(--acc); border-color: var(--acc-dim); background: var(--panel2); } +.hist-btn.pending { color: var(--amber); border-color: var(--amber); animation: hist-pulse 1.2s ease-in-out infinite; } +.hist-btn.failed { color: var(--red); border-color: var(--red); } +@keyframes hist-pulse { 50% { opacity: 0.55; } } +.hist-n { color: var(--dim); font-size: 0.85em; margin-left: 5px; } +.hist-btn.on .hist-n { color: var(--acc-dim); } +.hist-pop { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + right: 0; + width: min(560px, 88vw); + background: var(--inset); + border: 1px solid var(--acc-dim); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7); +} +.hist-pop .logpanel { max-height: 46vh; border: none; } + +/* what's new — same button + popover shell as the log next door, own body. + The dot is red because it means "shipped in the last two days", not a + semantic down/error; nothing else in the header uses a filled dot. */ +.news-dot { + display: inline-block; + width: 6px; + height: 6px; + margin-left: 6px; + border-radius: 50%; + background: var(--red); + vertical-align: middle; +} +.news-hd { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 5px 10px; + border-bottom: 1px solid var(--line); + color: var(--dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.news-list { + max-height: 46vh; + overflow-y: auto; + padding: 2px 10px 8px; + font-size: 0.9em; + line-height: 1.5; +} +.news-e { padding: 7px 0; } +.news-e + .news-e { border-top: 1px dashed var(--line); } +.news-e-hd { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } +.news-date { color: var(--dim); white-space: nowrap; } +.news-tag { font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.5px; } +.news-tag.new { color: var(--acc); } +.news-tag.fix { color: var(--dim); } +.news-tag.perf { color: var(--cyan); } +.news-title { color: var(--fg); } +.news-item { color: var(--dim); } +.news-item::before { content: '> '; color: var(--line-hi); } + +/* column-header explainer: ⓘ in a th, popover rendered fixed at the tab root + (the sticky thead is a stacking context and the table scroll-clips) */ +.info-btn { + background: none; + border: none; + color: var(--dim); + cursor: pointer; + padding: 0 3px; + font-size: 1em; +} +.info-btn:hover { color: var(--fg); } +.info-pop { + position: fixed; + z-index: 70; + width: min(320px, 88vw); + background: var(--inset); + border: 1px solid var(--acc-dim); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7); + padding: 9px 11px; + color: var(--dim); + line-height: 1.55; + text-align: left; + text-transform: none; + letter-spacing: 0; + white-space: normal; + font-weight: 400; + font-size: 0.92em; +} +.info-pop b { color: var(--fg); font-weight: 700; } + +/* ---------- pair label → address card (PairAddrs) ---------- + The trigger inherits its type from the caller (a table cell's bold pair name, + a card title), so it resets everything a