feat: SHEEP CHOICE routing, cross-chain deposits, and any-token ZAP

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) <noreply@anthropic.com>
This commit is contained in:
labrinyang
2026-07-23 00:01:52 +08:00
parent d73eaf873a
commit bca538e7e3
100 changed files with 10659 additions and 1639 deletions
+63
View File
@@ -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<Record<BridgeProviderId, () => Promise<BridgeQuote>>> = {
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`}`)
+52 -40
View File
@@ -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<readonly [bigint, number, number, number, number, boolean]>(slotRes[i * 6])
const t0 = ok<Address>(slotRes[i * 6 + 1])
const t1 = ok<Address>(slotRes[i * 6 + 2])
const ts = ok<number>(slotRes[i * 6 + 3])
const liq = ok<bigint>(slotRes[i * 6 + 4]) ?? 0n
const gauge = ok<Address>(slotRes[i * 6 + 5])
if (!s0 || !t0 || !t1 || ts === undefined) return
clPools.push({ addr: p, sqrtP: s0[0], tick: s0[1], t0, t1, ts, liq, gauge })
const s0 = ok<readonly [bigint, number, number, number, number, boolean]>(slotRes[i * 7])
const t0 = ok<Address>(slotRes[i * 7 + 1])
const t1 = ok<Address>(slotRes[i * 7 + 2])
const ts = ok<number>(slotRes[i * 7 + 3])
const fee = ok<number>(slotRes[i * 7 + 4])
const liq = ok<bigint>(slotRes[i * 7 + 5]) ?? 0n
const gauge = ok<Address>(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)
+2 -15
View File
@@ -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',
+7 -9
View File
@@ -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<string, TabId> = { '1': 'pools', '2': 'positions', '3': 'swap' }
const KEYS: Record<string, TabId> = { '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() {
<div className="app">
<Header tab={tab} onTab={setTab} />
<div className="main">
{isConnected && chainId !== CHAIN_ID && (
{isConnected && chainId !== CHAIN_ID && tab !== 'bridge' && (
<div className="banner">
{t('app.wrongNetwork')}
<Btn onClick={() => switchChain({ chainId: CHAIN_ID })}>{t('app.switch')}</Btn>
@@ -106,14 +105,13 @@ function Shell() {
{tab === 'pools' && (location.hash === '#lab' ? <LabTab /> : <PoolsTab />)}
{tab === 'positions' && <PositionsTab />}
{tab === 'swap' && <SwapTab />}
{tab === 'bridge' && <BridgeTab />}
</div>
<TxLogPanel />
<div className="footer">
<span>{t('app.tagline')}</span>
<span>{t('app.keys')}</span>
<span className="hide-m">{t('app.tagline')}</span>
<span className="hide-m">{t('app.keys')}</span>
<RpcControl />
<ThemeControl />
<LangControl />
<a href={EXPLORER} target="_blank" rel="noreferrer">
{t('app.blockscout')}
</a>
+23 -2
View File
@@ -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',
])
+42
View File
@@ -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 (
<a
className="ds-btn"
href={href}
target="_blank"
rel="noreferrer noopener external"
title={t('pools.dsTip')}
// the table row / card header behind this is its own toggle
onClick={(e) => e.stopPropagation()}
>
<img src={DS_ICON} alt="" aria-hidden="true" />
DS
</a>
)
}
+18 -15
View File
@@ -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 (
<div className="hdr">
{/* 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 */}
<span className="brand">
LP<span className="cursor"></span>TERMINAL
LP<span className="cursor"></span>
<span className="hide-m">TERMINAL</span>
</span>
<div className="tabs">
{TABS.map((tb) => (
@@ -34,15 +38,14 @@ export function Header(props: { tab: TabId; onTab: (t: TabId) => void }) {
</button>
))}
</div>
<span className="hdr-meta">
{t('hdr.epoch')} <b>{p ? p.epochCount : '…'}</b> · {t('hdr.flip')} <b>{fmtDur(epoch.secsLeft)}</b>
{p ? (
<>
{' '}
· {t('hdr.blk')} <b>{p.blockNumber.toString()}</b>
</>
) : null}
</span>
{p && (
<span className="hdr-meta">
{t('hdr.blk')} <b>{p.blockNumber.toString()}</b>
</span>
)}
<HistoryButton />
<NewsButton />
<LangControl />
<ConnectButton.Custom>
{({ account, chain, openAccountModal, openChainModal, openConnectModal, mounted }) => {
if (!mounted) return <button className="btn ghost"></button>
@@ -61,7 +64,7 @@ export function Header(props: { tab: TabId; onTab: (t: TabId) => void }) {
return (
<button className="btn ghost" onClick={openAccountModal}>
[{account.displayName}
{account.displayBalance ? ` · ${account.displayBalance}` : ''}]
<span className="hide-m">{account.displayBalance ? ` · ${account.displayBalance}` : ''}</span>]
</button>
)
}}
+38
View File
@@ -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 (
<div className="hist">
<button
className={`hist-btn ${open ? 'on' : ''} ${pending ? 'pending' : ''} ${failed ? 'failed' : ''}`}
onClick={() => setOpen(!open)}
title={t('hdr.historyTip')}
>
{pending ? '⧗' : failed ? '✗' : '≡'} <span className="hide-m">{t('hdr.history')}</span>
{lines.length > 0 && <span className="hist-n">{lines.length}</span>}
</button>
{open && (
<>
<div className="tsel-backdrop" onClick={() => setOpen(false)} />
<div className="hist-pop">
<TxLogPanel />
</div>
</>
)}
</div>
)
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { currentLang, setLang, type Lang } from '../i18n'
const LABELS: Record<Lang, string> = { 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
+63
View File
@@ -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 (
<div className="hist">
<button
className={`hist-btn ${open ? 'on' : ''}`}
onClick={() => setOpen(!open)}
title={t('news.tip')}
>
<span className="hide-m">{t('news.label')}</span>
{fresh > 0 && <span className="news-dot" title={t('news.freshTip', { n: fresh })} />}
</button>
{open && (
<>
<div className="tsel-backdrop" onClick={() => setOpen(false)} />
<div className="hist-pop news-pop">
<div className="news-hd">
<span>{t('news.title')}</span>
<button className="chip" onClick={() => setOpen(false)}>
×
</button>
</div>
<div className="news-list">
{CHANGELOG.length === 0 && <div className="news-item dim">{t('news.empty')}</div>}
{CHANGELOG.map((e) => (
<div className="news-e" key={e.id}>
<div className="news-e-hd">
<span className="news-date" title={e.date}>
{e.date.slice(5)}
</span>
<span className={`news-tag ${e.tag}`}>{e.tag}</span>
<span className="news-title">{e.title[lang]}</span>
{isFresh(e, now) && <span className="news-dot" />}
</div>
{e.items.map((it) => (
<div className="news-item" key={it.en}>
{it[lang]}
</div>
))}
</div>
))}
</div>
</div>
</>
)}
</div>
)
}
+187
View File
@@ -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<At | null>(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<string | null>(null)
const [failed, setFailed] = useState<string | null>(null)
const timer = useRef<ReturnType<typeof setTimeout>>()
const pop = useRef<HTMLDivElement>(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<HTMLButtonElement>) => {
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 ? (
<span className="addr-ok">{t('pools.addrCopied')}</span>
) : failed === addr ? (
<span className="addr-bad">{t('pools.addrFailed')}</span>
) : (
<span className="dim"></span>
)
return (
<>
<button
className={`pair-btn ${props.className ?? ''} ${at ? 'on' : ''}`}
onClick={open}
title={t('pools.addrTip')}
aria-haspopup="dialog"
aria-expanded={!!at}
>
{props.sym0}/{props.sym1}
</button>
{/* Portaled to <body>: 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(
<>
<div
className="tsel-backdrop"
onClick={(e) => {
e.stopPropagation()
setAt(null)
}}
/>
<div
ref={pop}
className="addr-pop"
style={{ top: at.top, left: at.left }}
onClick={(e) => e.stopPropagation()}
>
<div className="addr-pop-head">
<b>
{props.sym0}/{props.sym1}
</b>
<span className="dim">{t('pools.addrHint')}</span>
</div>
{entries.map((e) => (
<div className="addr-row" key={e.addr + e.k}>
<span className="addr-k">{e.k}</span>
<button className="addr-copy" onClick={() => copy(e.addr)} title={t('pools.addrCopyTip')}>
<span className="addr-v">{shortAddr(e.addr)}</span>
{status(e.addr)}
</button>
<a
className="dim addr-ext"
href={`${EXPLORER}/${e.token ? 'token' : 'address'}/${e.addr}`}
target="_blank"
rel="noreferrer"
title={t('pools.addrExplorerTip')}
>
</a>
</div>
))}
</div>
</>,
document.body,
)}
</>
)
}
+53 -11
View File
@@ -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 (
<div className="rbar-wrap">
{!order && (!leftUnbounded || !rightUnbounded) && (
<div className="rbar-holds mono-sm">
{leftUnbounded ? (
<span />
) : (
<span className={heldLow ? 'red' : 'dim'}> {t('rbar.allOf', { sym: base })}</span>
)}
{rightUnbounded ? (
<span />
) : (
<span className={heldHigh ? 'red' : 'dim'}>{t('rbar.allOf', { sym: quote })} </span>
)}
</div>
)}
<div className="rbar">
<span className="rbar-price">{fmtNum(dLower)}</span>
<span className="rbar-price">{leftUnbounded ? '0' : fmtNum(dLower)}</span>
<div
className="rbar-track"
title={t('rbar.ticksTip', { lo: tickLower, hi: tickUpper, tick })}
@@ -142,12 +179,11 @@ export function RangeBar(props: {
)}
<div className={`rbar-marker ${tone}`} style={{ left: `${fracPct}%` }} />
</div>
<span className="rbar-price">{fmtNum(dUpper)}</span>
<span className="rbar-price">{rightUnbounded ? INF : fmtNum(dUpper)}</span>
</div>
<div className="rbar-sub">
<span className={!order && !inRange && dCur < dLower ? 'red' : 'dim'}>
{toLeft >= 0 ? '+' : ''}
{fmtNum(toLeft, 3)}% {t('rbar.toLow')}
<span className={heldLow ? 'red' : 'dim'}>
{leftMove} {t('rbar.toLow')}
</span>
<span>
px{' '}
@@ -161,14 +197,20 @@ export function RangeBar(props: {
</button>
</span>
<span className={!order && !inRange && dCur > dUpper ? 'red' : 'dim'}>
{t('rbar.fromHigh')} {toRight >= 0 ? '+' : ''}
{fmtNum(toRight, 3)}%
<span className={heldHigh ? 'red' : 'dim'}>
{t('rbar.fromHigh')} {rightMove}
</span>
</div>
<div className="rbar-sub">
<Flash v={order ? order.fillFrac : undefined} arrow>
<span className={statusTone}>{statusText}</span>
<span
className={statusTone}
title={
!order && inRange ? t('rbar.inRangeTip', { pct: posPct.toFixed(1), band: bandStr }) : undefined
}
>
{statusText}
</span>
</Flash>
</div>
</div>
+27
View File
@@ -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 (
<label
className={`stake-toggle mono-sm${props.disabled ? ' off' : ''}`}
title={t('add.stakeAfterTip')}
>
<input
type="checkbox"
checked={on}
disabled={props.disabled}
onChange={(e) => autostake.set(e.target.checked)}
/>
{t('add.stakeAfter')}
</label>
)
}
+49 -13
View File
@@ -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<TokenInfo> => {
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 (
<div className="tsel">
<button className="tsel-btn" onClick={() => setOpen(!open)}>
{props.value.symbol}
{props.label ?? props.value.symbol}
</button>
{open && (
<>
@@ -44,22 +80,22 @@ export function TokenSelect(props: {
/>
</div>
{filtered.map((tok) => (
<div
key={tok.address}
className="tsel-item"
onClick={() => {
props.onChange(tok)
setOpen(false)
setQ('')
}}
>
<div key={tok.address} className="tsel-item" onClick={() => pick(tok)}>
<span>
{tok.symbol} {tok.native && <span className="dim">{t('common.gasToken')}</span>}
</span>
<span className="dim mono-sm">{tok.native ? NATIVE.slice(0, 8) : shortAddr(tok.address)}</span>
</div>
))}
{filtered.length === 0 && <div className="tsel-item dim">{t('common.noMatch')}</div>}
{unlisted && meta.data && (
<div className="tsel-item" onClick={() => pick(meta.data)}>
<span>{meta.data.symbol}</span>
<span className="dim mono-sm">{shortAddr(meta.data.address)}</span>
</div>
)}
{unlisted && meta.isLoading && <div className="tsel-item dim">{t('common.tokenResolving')}</div>}
{unlisted && meta.isError && <div className="tsel-item red">{t('common.tokenNotErc20')}</div>}
{filtered.length === 0 && !unlisted && <div className="tsel-item dim">{t('common.noMatch')}</div>}
</div>
</>
)}
+1 -1
View File
@@ -44,7 +44,7 @@ export function TxLogPanel() {
{glyph(l)} {l.text}
</span>
{l.hash && (
<a href={`${EXPLORER}/tx/${l.hash}`} target="_blank" rel="noreferrer">
<a href={l.href ?? `${EXPLORER}/tx/${l.hash}`} target="_blank" rel="noreferrer">
tx
</a>
)}
+328 -115
View File
@@ -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<string>()
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<TokenInfo | null>(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<number | null>(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<number | null>(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<ZapPlan | null>(null)
const [done, setDone] = useState(false)
const tokenInAddr: Address = sel === 'eth' ? NATIVE : sel === '0' ? t0.address : t1.address
const tIn: TokenInfo =
sel === 'eth' ? { address: NATIVE, symbol: 'ETH', decimals: 18, native: true } : sel === '0' ? t0 : t1
useEffect(() => {
const h = setTimeout(() => {
try {
@@ -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<string, Address>()
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 (
<div className="spec-row" key={i}>
<span className="sk">{p.legs.length > 1 ? t('zap.swapN', { n: i + 1 }) : t('zap.swapRow')}</span>
<span className="sv">
{fmtAmount(leg.swapIn, tIn.decimals)} {tIn.symbol} {fmtAmount(leg.estOut, tOut.decimals)}{' '}
{tOut.symbol}
</span>
<span className="sd">
{effectiveSlip === undefined
? t('zap.chooseSlippage')
: t('zap.swapMin', {
amt: fmtAmount(applySlippage(leg.estOut, effectiveSlip), tOut.decimals),
slip: effectiveSlip / 100,
})}
{leg.impactBps === null ? (
<span className="dim"> · {t('zap.impactOff')}</span>
) : (
<span className={slippageTone(leg.impactBps)}>
{' '}
· {t('zap.impact', { pct: (leg.impactBps / 100).toFixed(2) })}
</span>
)}
{/* zap still charges while market swaps are free — keep it bright */}
<span className="fg"> · {t('zap.terminalFee', { pct: (ENV.zapFeeBps / 100).toFixed(2) })}</span>
{' · '}
{t('zap.via', { route: zapRouteLabel(leg.via) })}
</span>
</div>
)
})
: null
return (
<div className="zap">
<div className="form-row">
<span className="lbl">{t('zap.zapIn')}</span>
<button className={`chip ${sel === '0' ? 'on' : ''}`} onClick={() => setSel('0')} disabled={running}>
{t0.symbol}
</button>
<button className={`chip ${sel === '1' ? 'on' : ''}`} onClick={() => setSel('1')} disabled={running}>
{t1.symbol}
</button>
{hasWeth && (
{candidates.map((c) => (
<button
className={`chip ${sel === 'eth' ? 'on' : ''}`}
onClick={() => setSel('eth')}
key={c.address}
className={`chip ${tokenInAddr.toLowerCase() === c.address.toLowerCase() ? 'on' : ''}`}
onClick={() => setSelTok(c)}
disabled={running}
title={t('zap.ethTip')}
title={c.native ? t('zap.ethTip') : undefined}
>
ETH
{c.symbol}
</button>
)}
))}
{customSel && <button className="chip on">{tIn.symbol}</button>}
<TokenSelect list={tokenList.tokens} value={tIn} onChange={setSelTok} label={t('zap.pickOther')} />
</div>
<div className="form-row">
<NumInput value={amtStr} onChange={setAmtStr} disabled={running} width={220} />
{spendable !== undefined && (
<>
<span className="dim mono-sm">
{t('common.bal')} {fmtAmount(spendable, tIn.decimals)}
</span>
<button className="chip" disabled={running} onClick={() => setAmtStr(formatUnits(spendable, tIn.decimals))}>
{t('common.max')}
</button>
<span className="dim mono-sm">
{t('common.bal')} {fmtAmount(spendable, tIn.decimals)} {tIn.symbol}
</span>
</>
)}
{insufficient && <span className="red mono-sm">{t('common.exceedsBalance')}</span>}
</div>
<div className="form-row">
<span className="lbl">{t('zap.slip')}</span>
{[50, 100, 300].map((b) => (
<button key={b} className={`chip ${slip === b ? 'on' : ''}`} onClick={() => setSlip(b)} disabled={running}>
{b / 100}%
{!showSlipEditor ? (
<button
className={`chip ${slipSummaryTone}`}
onClick={() => setSlipOpen(true)}
disabled={running}
title={t('zap.slipHint')}
>
{slipSummary}
</button>
))}
<span className="dim mono-sm">{t('zap.slipHint')}</span>
) : (
<>
<button
className={`chip ${manualSlip === null && customSlip === '' ? 'on' : ''}`}
onClick={() => {
setManualSlip(null)
setCustomSlip('')
}}
disabled={running}
>
AUTO {automaticSlippage ? `${automaticSlippage.bps / 100}%` : '—'}
</button>
{SLIPPAGE_CHOICES.map((b) => (
<button
key={b}
className={`chip ${manualSlip === b && customSlip === '' ? 'on' : ''}`}
onClick={() => {
setManualSlip(b)
setCustomSlip('')
}}
disabled={running}
>
{b / 100}%
</button>
))}
<NumInput
value={customSlip}
onChange={(v) => {
setCustomSlip(v)
setManualSlip(slippagePctToBps(v))
}}
disabled={running}
placeholder={t('common.slipCustom')}
width={96}
invalid={customInvalid}
/>
<span className="dim mono-sm">%</span>
{customInvalid && <span className="red mono-sm">{t('common.slipInvalid')}</span>}
{effectiveSlip !== undefined && (
<span className={`${slippageTone(effectiveSlip)} mono-sm`}> {effectiveSlip / 100}%</span>
)}
{!slipForced && (
<button className="chip" onClick={() => setSlipOpen(false)} disabled={running} title={t('zap.slipHint')}>
</button>
)}
</>
)}
</div>
{amount > 0n && plan.isLoading && (
@@ -233,39 +427,37 @@ export function ZapPanel(props: {
<div className="spec-row">
<span className="sk">{t('zap.split')}</span>
<span className="sv">
{p.swapIn === 0n
? t('zap.keepAll', { amt: fmtAmount(p.keep, tIn.decimals), sym: p.inIs0 ? t0.symbol : t1.symbol })
: t('zap.keepSwap', {
keep: fmtAmount(p.keep, tIn.decimals),
swap: fmtAmount(p.swapIn, tIn.decimals),
sym: p.inIs0 ? t0.symbol : t1.symbol,
})}
{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,
})}
</span>
<span className="sd">
{p.inIs0 !== null
? p.legs.length === 0
? t('zap.splitSdSingle')
: t('zap.splitSd')
: t('zap.outsideSd')}
</span>
<span className="sd">{p.swapIn === 0n ? t('zap.splitSdSingle') : t('zap.splitSd')}</span>
</div>
{p.swapIn > 0n && tOut && (
<div className="spec-row">
<span className="sk">{t('zap.swapRow')}</span>
<span className="sv">
{fmtAmount(p.estOut, tOut.decimals)} {tOut.symbol}
</span>
<span className="sd">
{t('zap.swapMin', { amt: fmtAmount(applySlippage(p.estOut, slip), tOut.decimals), slip: slip / 100 })}
{p.impactBps !== null &&
(p.impactBps < -500 ? (
// a >5% "gain" means kyber's USD marks are off for this token
// (common for launchpad tokens) — the number isn't actionable
<span> · {t('zap.impactOff')}</span>
) : (
<span className={p.impactBps > 300 ? ' red' : p.impactBps > 150 ? ' amber' : ''}>
{' '}
· {t('zap.impact', { pct: (p.impactBps / 100).toFixed(2) })}
</span>
))}
{p.routeLabel && <> · {t('zap.via', { route: p.routeLabel })}</>}
</span>
</div>
)}
{legRows}
<div className="spec-row">
<span className="sk">{t('zap.depositRow')}</span>
<span className="sv">
@@ -317,21 +509,29 @@ export function ZapPanel(props: {
</div>
)}
{planPaused && (
<div className="form-row">
<button
className="chip amber"
onClick={() => void plan.refetch()}
title={t('zap.refreshTip', { s: ZAP_PLAN_REFRESH_MS / 1000, n: SOLVER_AUTO_REFRESHES })}
>
{plan.isFetching ? <span className="spin"></span> : `${t('zap.refreshPlan')}`}
</button>
</div>
)}
{stages.length > 0 && (
<div className="zap-steps mono-sm">
{stages.map((s, i) => {
const state = !runAt
? 'todo'
: i < runAt.i
? 'done'
: i === runAt.i
? failed
? 'fail'
: running
? 'run'
: 'todo'
: 'todo'
const mark = state === 'done' ? '✓' : state === 'run' ? '▮' : state === 'fail' ? '✗' : '·'
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 (
<div key={i} className={`zstep ${state}`}>
<span className="zn">{i + 1}</span>
@@ -343,17 +543,30 @@ export function ZapPanel(props: {
</div>
)}
{failed && <div className="red mono-sm">{t('zap.halted', { n: (runAt?.i ?? 0) + 1 })}</div>}
{failed && (
<div className="red mono-sm">
{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 })}
</div>
)}
{done && <div className="green mono-sm">{t('zap.done')}</div>}
<div className="form-row">
<Btn busy={running} onClick={run} disabled={!user || amount === 0n || !plan.data || insufficient || running}>
{!user
? t('common.connectWallet')
: stages.length > 0
? t('zap.runTx', { n: stages.length })
: t('zap.run')}
<Btn
busy={running}
onClick={run}
disabled={!user || amount === 0n || !plan.data || insufficient || slippageRequired || running}
>
{runLabel}
</Btn>
{stakeable && <StakeAfterToggle disabled={running} />}
<span className="dim mono-sm">{t('zap.runHint')}</span>
</div>
</div>
+729
View File
@@ -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<number, bigint> = { 1: parseUnits('0.004', 18) }
const DEFAULT_GAS_BUFFER = parseUnits('0.0015', 18)
const PROVIDER_LABEL: Record<BridgeProviderId, string> = {
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<BridgeDir>('in')
const [symWanted, setSymWanted] = useState('ETH')
const [remote, setRemote] = useState<RemoteChain>(REMOTE_CHAINS[0])
const [amtStr, setAmtStr] = useState('')
const [amount, setAmount] = useState(0n)
const [override, setOverride] = useState<BridgeProviderId | null>(null)
const [feesOpen, setFeesOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [run, setRun] = useState<RunState>(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 && (
<>
<div className="section-title">{t('bridge.pendingTitle')}</div>
<div className="pend-list">
{pending.map((p) => (
<PendingRow key={p.id} p={p} now={now} />
))}
</div>
</>
)
const sendCard = (
<div className="swap-card">
<div className="hd">
<span className="side">
<span className="side-lbl">{t('bridge.from')}</span>
<ChainSel current={leg?.originChainId ?? (dir === 'in' ? remote.chain.id : CHAIN_ID)} busy={busy} tip={chainTip} onPick={pickChain('from')} />
</span>
{balIn !== undefined && leg ? (
<button
className={`bal ${insufficient ? 'red' : ''}`}
onClick={guard(() => setPct(100n))}
title={t('swap.balTip')}
>
{t('common.bal')} {fmtAmount(balIn, leg.inputDecimals)} {leg.inputSymbol}
</button>
) : balance.isError ? (
// a failed read must say so — an invisible balance reads as "you have none"
<button
className="bal red"
onClick={guard(() => void balance.refetch())}
title={t('bridge.balFailed', { err: (balance.error as Error).message.slice(0, 80) })}
>
{t('common.bal')}
</button>
) : null}
</div>
<div className="io">
<TokenSel options={options} value={token} busy={busy} loading={discovery.isLoading} onPick={setSymWanted} />
<input
className="amt"
inputMode="decimal"
autoComplete="off"
spellCheck={false}
autoFocus
placeholder="0.0"
value={amtStr}
disabled={busy}
onChange={(event) => {
const value = sanitizeAmountInput(event.target.value, token?.decimals ?? 18)
if (value !== null) setAmtStr(value)
}}
/>
</div>
<div className="ft">
<span className="pcts">
{balIn !== undefined &&
balIn > 0n &&
[25n, 50n, 75n, 100n].map((pct) => (
<button
key={pct.toString()}
className="chip"
title={pct === 100n && isNativeIn ? t('common.maxGasTip', { amt: fmtAmount(gasBuffer, 18), sym: 'ETH' }) : undefined}
onClick={guard(() => setPct(pct))}
>
{pct === 100n ? t('common.max') : `${pct}%`}
</button>
))}
</span>
<span className="usd">{inUsd !== undefined && <> {fmtUsd(inUsd)}</>}</span>
</div>
{discovery.isError && options.length === 0 && (
<div className="ft">
<span className="red mono-sm">{t('bridge.discoverFailed')}</span>
<button className="chip aux" onClick={guard(() => void discovery.refetch())}>
{t('bridge.retry')}
</button>
</div>
)}
</div>
)
const receiveCard = (
<div className="swap-card">
<div className="hd">
<span className="side">
<span className="side-lbl">{t('bridge.to')}</span>
<ChainSel current={leg?.destChainId ?? (dir === 'in' ? CHAIN_ID : remote.chain.id)} busy={busy} tip={chainTip} onPick={pickChain('to')} />
</span>
{balanceOut.data !== undefined && leg && (
<span className="bal static">
{t('common.bal')} {fmtAmount(balanceOut.data, leg.outputDecimals)} {leg.outputSymbol}
</span>
)}
</div>
<div className="io">
<span className="tsel-btn static">{token?.symbol ?? '—'}</span>
<span className={`out ${selected ? '' : 'dim'}`}>{outputDisplay}</span>
</div>
<div className="ft">
<span />
<span className="usd">
{outUsd !== undefined && <> {fmtUsd(outUsd)}</>}
{impactPct !== undefined && (
<span className={`delta ${impactPct <= -1 ? 'red' : ''}`} title={t('bridge.impactTip')}>
{' '}
({impactPct > 0 ? '+' : ''}
{impactPct.toFixed(2)}%)
</span>
)}
</span>
</div>
</div>
)
// 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<BridgeStage | 'done', string> = {
approve: t('bridge.stgApprove'),
deposit: t('bridge.stgDeposit'),
done: t('bridge.stgSent'),
}
const progressStrip = run && (
<div className="bridge-progress mono-sm">
{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 (
<span key={s} style={{ display: 'contents' }}>
{i > 0 && <span className="sep"></span>}
<span className={`stg ${cls}`}>
{isDone && '✓'}
{isCurrent && <span className="spin"></span>} {stageLabel[s]}
</span>
</span>
)
})}
{runOver && <span className="dim">{t('bridge.sentNote')}</span>}
<span className="elapsed">{elapsed}s</span>
</div>
)
return (
<div className="swap-box narrow">
<div className="swap-col">
{pendingSection}
{sendCard}
<div className="swap-flip-row">
<button
className="swap-flip"
onClick={guard(() => setDir(dir === 'in' ? 'out' : 'in'))}
title={t('bridge.flipTip')}
>
</button>
</div>
{receiveCard}
{amount > 0n && token && leg && (
<>
<div className="section-title">
{t('bridge.route')}
<button
className="chip aux"
onClick={guard(() => {
for (const id of netIds) void quotes[id].refetch()
})}
title={t('swap.refreshTip', { s: BRIDGE_QUOTE_REFRESH_MS / 1000 })}
>
{anyFetching ? <span className="spin"></span> : `${nextIn ?? '—'}s`}
</button>
</div>
{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 (
<div key={id} className={`quote-card ${isSel ? 'sel' : ''}`} onClick={guard(() => setOverride(id))}>
<div className="l1">
<span className="src">
{isSel ? '◉' : '○'} {PROVIDER_LABEL[id]}
</span>
{id === 'portal' && (
<span className="dim mono-sm" title={t('bridge.canonicalTip')}>
{t('bridge.canonical')}
</span>
)}
{query.isFetching && !quote && <span className="qstate spin"></span>}
{query.isError && !quote && (
<span className="qstate red mono-sm">
{((query.error as Error).message ?? '').slice(0, 70) || t('bridge.quoteFailed')}
</span>
)}
{quote && (
<>
<span className="qamt green">
{fmtAmount(quote.outputAmount, leg.outputDecimals)} {leg.outputSymbol}
</span>
{automatic === id ? (
<Badge tone="green">{t('swap.best')}</Badge>
) : (
behindBest !== null &&
Math.abs(behindBest) >= 0.005 && (
<span className={`${behindBest < 0 ? 'red' : 'green'} mono-sm`}>
{behindBest > 0 ? '+' : ''}
{behindBest.toFixed(2)}%
</span>
)
)}
</>
)}
</div>
{quote && (
<div className="l2">
<span>
{fmtEtaDisplay(quote.etaSec)}
{qImpact !== null && (
<span className={slippageTone(Math.max(0, Math.round(-qImpact * 100)))}>
{' '}
· {t('swap.quoteImpact', { pct: qImpact.toFixed(2) })}
</span>
)}
</span>
<span>
{t('bridge.minShort', {
amt: fmtAmount(quote.minOutput, leg.outputDecimals),
sym: leg.outputSymbol,
})}
</span>
</div>
)}
</div>
)
})}
{impactPct !== undefined && impactPct <= -1 && (
<div className="amber mono-sm">{t('bridge.impactWarn', { pct: impactPct.toFixed(2) })}</div>
)}
{selected && (
<>
<div className="section-title">{t('swap.details')}</div>
<div className="kv-list">
{rate !== undefined && (
<div className="kv">
<span className="k">{t('swap.kRate')}</span>
<span className="fill" />
<span className="v">
{t('swap.rateV', { a: leg.inputSymbol, n: fmtNum(rate), b: leg.outputSymbol })}
</span>
</div>
)}
<div className="kv">
<span className="k">{t('swap.kMinReceived')}</span>
<span className="fill" />
<span className="v">
{fmtAmount(selected.minOutput, leg.outputDecimals)} {leg.outputSymbol}
</span>
</div>
<div className="kv">
<span className="k">{t('bridge.kEta')}</span>
<span className="fill" />
<span className="v">{fmtEtaDisplay(selected.etaSec)}</span>
</div>
{/* fee breakdown only exists with a terminal fee — at 0 the
provider-cost row would just repeat IMPACT */}
<div
className={ENV.bridgeFeeBps > 0 ? 'kv click' : 'kv'}
onClick={ENV.bridgeFeeBps > 0 ? () => setFeesOpen(!feesOpen) : undefined}
title={t('bridge.impactTip')}
>
<span className="k">{t('swap.kImpact')}</span>
<span className="fill" />
<span className={`v ${costBps === null ? 'dim' : slippageTone(costBps)}`}>
{impactPct === undefined ? '—' : `${impactPct.toFixed(2)}%`}
{ENV.bridgeFeeBps > 0 && <span className="dim"> {feesOpen ? '▴' : '▾'}</span>}
</span>
</div>
{ENV.bridgeFeeBps > 0 && feesOpen && (
<>
<div className="kv sub">
<span className="k">{t('bridge.kProviderCost')}</span>
<span className="fill" />
<span className="v dim">
{impactPct === undefined
? '—'
: `${(impactPct + ENV.bridgeFeeBps / 100).toFixed(2)}%`}
</span>
</div>
<div
className="kv sub"
title={t('bridge.terminalFeeTip', { pct: (ENV.bridgeFeeBps / 100).toFixed(2) })}
>
<span className="k">{t('swap.kTerminalFee')}</span>
<span className="fill" />
<span className="v dim">{(ENV.bridgeFeeBps / 100).toFixed(2)}%</span>
</div>
</>
)}
</div>
</>
)}
</>
)}
{progressStrip}
<div className="swap-cta">
<Btn big busy={busy} disabled={ctaDisabled} onClick={!user ? () => openConnectModal?.() : doBridge}>
{cta}
</Btn>
</div>
</div>
</div>
)
}
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 (
<div className="pend-row">
<span className={p.status === 'filled' ? 'green' : p.status === 'pending' ? '' : 'amber'}>
{p.status === 'pending' && <span className="spin"></span>}
{p.status === 'filled' && '✓'}
{(p.status === 'refunded' || p.status === 'failed' || p.status === 'stale') && '⚠'}
</span>
<span className="amt">
{p.amountIn} {p.symbol}
</span>
<span className="prov">
{chainName(p.originChainId)}{chainName(p.destChainId)} · {PROVIDER_LABEL[p.provider]}
</span>
<a
className="dim"
href={`${explorerOf(p.originChainId)}/tx/${p.depositTxHash}`}
target="_blank"
rel="noopener noreferrer"
title={p.depositTxHash}
>
</a>
<span className={`eta ${p.status === 'stale' ? 'amber' : ''}`} title={p.status === 'stale' ? t('bridge.stale') : undefined}>
{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 ? (
<a
className="green"
href={`${explorerOf(p.destChainId)}/tx/${p.fillTxHash}`}
target="_blank"
rel="noopener noreferrer"
>
{t('bridge.pFilled')}
</a>
) : (
t('bridge.pFilled')
))}
{p.status === 'refunded' && t('bridge.pRefunded')}
{p.status === 'failed' && t('bridge.pFailed')}
{p.status === 'stale' && t('bridge.pStale')}
</span>
{canRecheck && (
<button className="x" onClick={() => recheckPending(p)} title={t('bridge.recheckTip')}>
</button>
)}
<button className="x" onClick={() => pendingBridges.dismiss(p.id)} title={t('bridge.dismissTip')}>
</button>
</div>
)
}
/** 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 (
<div className="tsel">
<button
className="tsel-btn"
title={t('bridge.tokenTip')}
onClick={() => setOpen(!props.busy && !open)}
onKeyDown={(e) => e.key === 'Escape' && setOpen(false)}
>
{props.value?.symbol ?? (props.loading ? '…' : '—')}
</button>
{open && (
<>
<div className="tsel-backdrop" onClick={() => setOpen(false)} />
<div className="tsel-pop">
{props.options.map((o) => (
<div
key={o.symbol}
className="tsel-item"
onClick={() => {
props.onPick(o.symbol)
setOpen(false)
}}
>
<span>
{props.value?.symbol === o.symbol ? '◉' : '○'} {o.symbol}
</span>
<span className="dim mono-sm">
{o.providers.length === 1 ? t('bridge.nRoute1') : t('bridge.nRoutes', { n: o.providers.length })}
</span>
</div>
))}
{props.options.length === 0 && (
<div className="tsel-item dim">{props.loading ? t('bridge.discovering') : t('bridge.noTokens')}</div>
)}
</div>
</>
)}
</div>
)
}
/** 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 (
<span className="chain-sel" onKeyDown={(e) => e.key === 'Escape' && setOpen(false)}>
<button className="chain-btn" title={props.tip} onClick={() => setOpen(!props.busy && !open)}>
{chainName(props.current)} <span className="caret"></span>
</button>
{open && (
<>
<div className="tsel-backdrop" onClick={() => setOpen(false)} />
<div className="chain-pop">
{[CHAIN_ID, ...REMOTE_CHAINS.map((r) => r.chain.id)].map((id) => (
<button
key={id}
className={`tsel-item ${id === props.current ? 'cur' : ''}`}
onClick={() => {
setOpen(false)
if (id !== props.current) props.onPick(id)
}}
>
<span>
{id === props.current ? '◉' : '○'} {chainName(id)}
</span>
{id === CHAIN_ID && <span className="dim mono-sm">{t('bridge.homeTag')}</span>}
</button>
))}
</div>
</>
)}
</span>
)
}
+43 -1
View File
@@ -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() {
)
})()}
<div className="section-title">POOL GROUP same-pool aggregation (2+ positions collapse under one header)</div>
{(() => {
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 (
<ClPoolGroup
positions={[staked1, staked2]}
metricsOf={(p) =>
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}
/>
)
})()}
<div className="section-title">INCREASE PANEL LAB (synthetic positions)</div>
{(() => {
const inRange = labPos(100000, 104000, 500_000_000_000_000_000_000n)
+241 -86
View File
@@ -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<string | null>(null)
const [sort, setSort] = useState<SortKey>('tvl') // browse default: biggest pools first
const [sort, setSortState] = useState<SortKey>(rememberedSort)
const [onlyMine, setOnlyMine] = useState(false)
const [proto, setProto] = useState<ProtoFilter>('all')
const [proto, setProtoState] = useState<ProtoFilter>(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<HTMLInputElement>(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<string, PoolStat> = {}
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 (
<div className="dim">
@@ -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<SortKey, null>, label: string) => (
const th = (key: Exclude<SortKey, null>, label: string, extra = '', sub?: string, info = false) => (
<th
className={`num sortable ${sort === key ? 'on' : ''}`}
className={`num sortable ${extra} ${sort === key ? 'on' : ''}`}
onClick={() => 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 && (
<button
className="info-btn"
title={t('pools.footnoteTip')}
onClick={(e) => {
e.stopPropagation() // the th click sorts
if (aprInfo) {
setAprInfo(null)
return
}
const r = e.currentTarget.getBoundingClientRect()
setAprInfo({ top: r.bottom + 6, right: Math.max(8, window.innerWidth - r.right - 4) })
}}
>
</button>
)}
{/* mobile stacks a second metric into this column; name it in the header */}
{sub && <span className="cell-sub show-m">{sub}</span>}
</th>
)
@@ -218,13 +294,20 @@ export function PoolsTab() {
<thead>
<tr>
<th>{t('pools.thPair')}</th>
<th>{t('pools.thPrice')}</th>
{th('tvl', t('pools.thTvl'))}
{th('vol', t('pools.thVol'))}
{th('fees24', t('pools.thFees'))}
{th('feeApr', t('pools.thFeeApr'))}
{th('rewards', t('pools.thRewards'))}
<th></th>
{/* 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. */}
<th className="hide-t">{t('pools.thPrice')}</th>
{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 */}
<th className="hide-t"></th>
</tr>
</thead>
<tbody>
@@ -246,9 +329,14 @@ export function PoolsTab() {
</tbody>
</table>
</div>
<div className="dim mono-sm" style={{ marginTop: 6 }} title={t('pools.footnoteTip')}>
<Trans i18nKey="pools.footnote" components={[<b className="dim" key="0" />, <b className="dim" key="1" />]} />
</div>
{aprInfo && (
<>
<div className="tsel-backdrop" onClick={() => setAprInfo(null)} />
<div className="info-pop" style={{ top: aprInfo.top, right: aprInfo.right }}>
<Trans i18nKey="pools.footnote" components={[<b key="0" />, <b key="1" />]} />
</div>
</>
)}
</div>
)
}
@@ -281,18 +369,35 @@ function PoolRow(props: {
return (
<>
<tr className="rowhover">
<td>
<div>
{props.mine && (
<span className="mydot" title={t('pools.mineDotTip')}>
</span>
)}
<b>
{t0.symbol}/{t1.symbol}
</b>
{p.protocol !== 'up33' && <ProtoBadge proto={p.protocol} mini />}
<tr
className={`rowhover${props.mine ? ' is-mine' : ''}${props.open ? ' is-open' : ''}`}
onClick={(e) => {
// 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()
}}
>
<td title={props.mine ? t('pools.mineDotTip') : undefined}>
<div className="pair-line">
<PairAddrs
className="pair-name"
sym0={t0.symbol}
sym1={t1.symbol}
token0={p.token0}
token1={p.token1}
pool={p.address}
/>
{/* 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. */}
<ProtoBadge proto={p.protocol} mini />
<DexScreenerLink pool={p.address} />
</div>
<div className="pair-sub">
<span className="cyan">
@@ -314,23 +419,17 @@ function PoolRow(props: {
>
{feePct.toFixed(feePct < 0.1 ? 3 : 2)}%
</span>
{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 ? (
<span className="green">{t('pools.gauge')}</span>
) : (
<span className="red">{t('pools.killed')}</span>
)
) : (
<span>{t('pools.noGauge')}</span>
)}
<span className="red">{t('pools.killed')}</span>
</>
)}
</div>
</td>
<td className="mono-sm">
<td className="mono-sm hide-t">
{p.kind === 'v2' ? (
<>
{fmtCompactAmount(p.reserve0, t0.decimals)} {t0.symbol} + {fmtCompactAmount(p.reserve1, t1.decimals)} {t1.symbol}
@@ -340,18 +439,42 @@ function PoolRow(props: {
)}
</td>
<td className="num">
{stat?.liqUsd != null && stat.liqUsd > 0 ? fmtUsd(stat.liqUsd) : <span className="dim"></span>}
<Flash v={stat?.liqUsd}>
{stat?.liqUsd != null && stat.liqUsd > 0 ? (
<>
<span className="hide-m">{fmtUsd(stat.liqUsd)}</span>
{/* phone columns: $2.8M, not $2,844,349 */}
<span className="show-m">${fmtCompact(stat.liqUsd)}</span>
</>
) : (
<span className="dim"></span>
)}
</Flash>
{/* phone: VOL 24H stacks under TVL */}
<span className="cell-sub show-m">
{stat?.vol24hUsd != null ? `$${fmtCompact(stat.vol24hUsd)}` : '—'}
</span>
</td>
<td className="num">
{stat?.vol24hUsd != null ? fmtUsd(stat.vol24hUsd) : <span className="dim"></span>}
<td className="num hide-m">
<Flash v={stat?.vol24hUsd}>
{stat?.vol24hUsd != null ? fmtUsd(stat.vol24hUsd) : <span className="dim"></span>}
</Flash>
</td>
<td className="num">
<td className="num hide-m">
{fees24 != null ? <span className="amber">{fmtUsd(fees24)}</span> : <span className="dim"></span>}
</td>
<td className="num" title="unstaked LP net fee yield (staked LPs earn 0 fees)">
{feeApr != null ? fmtApr(feeApr) : <span className="dim"></span>}
{/* phone: reward APR stacks under fee APR */}
<span className="cell-sub show-m">
{p.protocol === 'up33' && emitApr != null ? (
<span className="green">{fmtApr(emitApr)}</span>
) : (
'—'
)}
</span>
</td>
<td className="num" title={t('pools.rewardsTip')}>
<td className="num hide-m" title={t('pools.rewardsTip')}>
{p.protocol === 'up33' ? (
<>
{emitApr != null ? <span className="green">{fmtApr(emitApr)}</span> : <span className="dim"></span>}
@@ -367,7 +490,8 @@ function PoolRow(props: {
<span className="dim"></span>
)}
</td>
<td className="num">
{/* phone and tablet: the row itself is the toggle, the button column just steals width */}
<td className="num hide-t">
<Btn tone="ghost" onClick={props.onToggle}>
{props.open ? t('common.close') : t('pools.addLp')}
</Btn>{' '}
@@ -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
<Btn busy={busy} onClick={add} disabled={!user}>
{t('add.addLiquidity')}
</Btn>
{poolStakeable(pool) && <StakeAfterToggle disabled={busy} />}
<span className="dim mono-sm">
{t('add.v2Hint', { slip: SLIP_BPS / 100 })} · {uni2 ? t('add.v2HintUni') : t('add.v2HintUp33')}
</span>
@@ -594,13 +727,6 @@ export function FundSwitch(props: { fund: 'pair' | 'zap'; onFund: (f: 'pair' | '
return (
<div className="form-row">
<span className="lbl">{t('add.fund')}</span>
<button
className={`chip ${props.fund === 'pair' ? 'on' : ''}`}
onClick={() => props.onFund('pair')}
title={t('add.fundPairTip')}
>
{t('add.fundPair')}
</button>
<button
className={`chip ${props.fund === 'zap' ? 'on' : ''}`}
onClick={() => props.onFund('zap')}
@@ -608,6 +734,13 @@ export function FundSwitch(props: { fund: 'pair' | 'zap'; onFund: (f: 'pair' | '
>
{t('add.fundZap')}
</button>
<button
className={`chip ${props.fund === 'pair' ? 'on' : ''}`}
onClick={() => props.onFund('pair')}
title={t('add.fundPairTip')}
>
{t('add.fundPair')}
</button>
</div>
)
}
@@ -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<RangeMode>('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({
<button className={`chip ${mode === 'full' ? 'on' : ''}`} onClick={() => setMode('full')}>
{t('add.full')}
</button>
{/* one-sided / custom-% / price / tick modes live behind MORE — five
extra controls the common preset flow never needs to look at */}
<button
className={`chip ${advActive ? 'on' : ''}`}
onClick={() => setAdvOpen(!advOpen)}
title={t('add.moreTip')}
>
{t('add.more')} {advOpen || advActive ? '▴' : '▾'}
</button>
</div>
{(advOpen || advActive) && (
<div className="form-row">
<span className="lbl"></span>
<button className={`chip ${mode === 'pct' ? 'on' : ''}`} onClick={() => setMode('pct')} title={t('add.pctCustomTip')}>
@@ -899,6 +1052,7 @@ export function AddCl({
</span>
)}
</div>
)}
{ticks && (
<RangeBar
tickLower={ticks.lower}
@@ -952,6 +1106,7 @@ export function AddCl({
<Btn busy={busy} onClick={mint} disabled={!user || !ticks}>
{t('add.mint')}
</Btn>
{poolStakeable(pool) && <StakeAfterToggle disabled={busy} />}
<span className="dim mono-sm">
{pool.protocol === 'univ3' ? t('add.mintHintUni') : t('add.mintHintUp33')}
</span>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -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 (
<input
className="input"
className={`input${props.invalid ? ' invalid' : ''}`}
style={props.width ? { width: props.width } : undefined}
inputMode="decimal"
autoComplete="off"
@@ -90,8 +93,8 @@ export function NumInput(props: {
value={props.value}
disabled={props.disabled}
onChange={(e) => {
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)
}}
/>
)
+5 -1
View File
@@ -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<string, Address>
+96
View File
@@ -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 (484689s over 3
* samples, 2026-07-18) — quote ~10 min, not the nominal "5 min" */
export const PORTAL_ETA_SEC = 600
+49 -13
View File
@@ -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
}
+53 -3
View File
@@ -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/<net> 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
}
+123
View File
@@ -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: '资金在路上时有倒计时,到账后终端会告诉你。',
},
],
},
]
+2 -2
View File
@@ -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()
+29
View File
@@ -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<bigint> => {
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,
})
},
})
}
+63
View File
@@ -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<BridgeProviderId, UseQueryResult<BridgeQuote>>
/** 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
}
+15
View File
@@ -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),
})
}
-15
View File
@@ -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 }
}
+2 -1
View File
@@ -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()
+75
View File
@@ -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<boolean> => {
const client = getPublicClient(wagmiConfig, { chainId: asConfiguredChain(CHAIN_ID) })
const rcpt = await client.getTransactionReceipt({ hash: childTxHash }).catch(() => null)
return rcpt !== null
}
const inflight = new Set<string>()
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
}
+57
View File
@@ -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<string>()
const lastStalePoll = new Map<string, number>()
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
}
+19 -1
View File
@@ -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,
})
}
+2 -2
View File
@@ -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<PoolsData> {
}
export function usePools() {
const pc = usePublicClient()
const pc = usePublicClient({ chainId: CHAIN_ID })
return useQuery({
queryKey: ['pools'],
enabled: !!pc,
+126 -7
View File
@@ -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<string, TokenInfo> }> {
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<Address>(det[base])
const token0 = ok<Address>(det[base + 1])
const token1 = ok<Address>(det[base + 2])
const reserves = ok<readonly [bigint, bigint, number]>(det[base + 3])
const totalSupply = ok<bigint>(det[base + 4]) ?? 0n
const walletLp = ok<bigint>(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<string, TokenInfo> = {}
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<string>(meta[j * 2]) ?? a.slice(0, 6) + '…',
decimals: ok<number>(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<PositionsData> {
// 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],
+117 -99
View File
@@ -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<object, number>()
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<NativeQuote> {
const tIn = erc20Of(tokenIn).toLowerCase()
const tOut = erc20Of(tokenOut).toLowerCase()
const matches = pools.pools.filter((p) => {
const a = p.token0.toLowerCase()
const b = p.token1.toLowerCase()
return (a === tIn && b === tOut) || (a === tOut && b === tIn)
})
if (!matches.length || tIn === tOut) return { best: null, candidates: [] }
const calls = matches.map((p) =>
p.kind === 'v2'
? {
abi: v2RouterAbi,
address: ADDR.V2_ROUTER,
functionName: 'getAmountsOut',
args: [
amountIn,
[{ from: erc20Of(tokenIn), to: erc20Of(tokenOut), stable: p.stable, factory: ADDR.V2_FACTORY }],
],
}
: {
abi: quoterAbi,
address: ADDR.CL_QUOTER,
functionName: 'quoteExactInputSingle',
args: [
{
tokenIn: erc20Of(tokenIn),
tokenOut: erc20Of(tokenOut),
amountIn,
tickSpacing: p.tickSpacing,
sqrtPriceLimitX96: 0n,
},
],
},
)
const res = (await pc.multicall({ contracts: calls as never })) as {
status: 'success' | 'failure'
result?: unknown
}[]
const candidates: NativeCandidate[] = []
res.forEach((r, i) => {
if (r.status !== 'success') return
const p = matches[i]
if (p.kind === 'v2') {
const amounts = r.result as readonly bigint[]
const out = amounts[amounts.length - 1]
if (out > 0n) candidates.push({ kind: 'v2', pool: p, amountOut: out })
} else {
const [out] = r.result as readonly [bigint, bigint, number, bigint]
if (out > 0n) candidates.push({ kind: 'cl', pool: p, amountOut: out })
}
})
candidates.sort((a, b) => (b.amountOut > a.amountOut ? 1 : b.amountOut < a.amountOut ? -1 : 0))
return { best: candidates[0] ?? null, candidates }
}
export function useNativeQuote(tokenIn?: Address, tokenOut?: Address, amountIn?: bigint) {
const pc = usePublicClient()
const pools = usePools()
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<SolverQuoteResult>({
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,
)
},
})
}
+25 -19
View File
@@ -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<string, TokenInfo>()
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,
}
}
+31
View File
@@ -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)
}
},
})
}
+12 -1
View File
@@ -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
},
})
+4 -2
View File
@@ -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<UniPoolsData>({
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' }
+10 -17
View File
@@ -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)
}
+168 -51
View File
@@ -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)',
},
}
+165 -51
View File
@@ -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: '用单边 LPCL 区间订单)卖出代币',
chainTip: '网络标识 — 本终端的所有报价与交易均在 Robinhood Chainid {{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: '从列表移除(不影响转账本身)',
},
}
+1 -1
View File
@@ -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.
+19
View File
@@ -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)
},
}
+122
View File
@@ -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<BridgeQuote> {
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 }
}
+518
View File
@@ -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<string, unknown>; 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<string, unknown>
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<string, RelayCurrencyV2[]> = {
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<string, string>()
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
}
})
+103
View File
@@ -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<boolean> {
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<BridgeOutcome> {
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'
}
+203
View File
@@ -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<PendingTransfer>) {
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<boolean>
/** 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<Partial<PendingTransfer>> {
const base: Partial<PendingTransfer> = { 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
}
}
+105
View File
@@ -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 484689s 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,
}
}
/** L1L2 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 EthereumRobinhood 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)
}
+125
View File
@@ -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<BridgeQuote> {
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 }
}
+319
View File
@@ -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<string, RelayCurrencyV2[]>
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<string, Candidate>()
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<RelayChainsJson> | null = null
let routesMemo: Promise<AcrossRouteJson[]> | null = null
function fetchRelayChains(): Promise<RelayChainsJson> {
chainsMemo ??= fetch(`${RELAY_API}/chains`)
.then((r) => {
if (!r.ok) throw new Error(`relay chains ${r.status}`)
return r.json() as Promise<RelayChainsJson>
})
.catch((e) => {
chainsMemo = null // do not cache failures
throw e
})
return chainsMemo
}
function fetchAcrossRoutes(): Promise<AcrossRouteJson[]> {
routesMemo ??= fetch(`${ACROSS_API}/available-routes`)
.then((r) => {
if (!r.ok) throw new Error(`across routes ${r.status}`)
return r.json() as Promise<AcrossRouteJson[]>
})
.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<boolean> {
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<RelayCurrencyV2[]> {
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<BridgeTokenSupport[]> {
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<string>(
(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<string, RelayCurrencyV2[]> = {}
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 })
}
+50
View File
@@ -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
}
+40
View File
@@ -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<boolean> {
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()
}
}
+50
View File
@@ -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,<script>alert(1)</script>',
'//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, '')
}
})
+33
View File
@@ -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 <a rel="noreferrer noopener">, 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)
}
+499
View File
@@ -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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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<string, unknown>[] }) => {
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/,
)
})
+543
View File
@@ -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<DirectProtocol, DirectCandidate | null>
status: Record<DirectProtocol, 'quoted' | 'absent' | 'failed'>
/** 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<string>()
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<DirectProtocol>
}
async function directRoutes(
client: PublicClient,
pools: readonly Pool[] | null,
tokenIn: Address,
tokenOut: Address,
): Promise<RouteDiscovery> {
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<DirectProtocol>()
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<DirectProtocol>,
): 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<DirectProtocol> }> {
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<DirectProtocol>()
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<DirectQuotes> {
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<DirectCandidate> {
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}`
}
+66
View File
@@ -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('005', 6), '0.05')
assert.equal(sanitizeAmountInput('0.05', 18), '0.05')
assert.equal(sanitizeAmountInput('005', 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)
})
+61 -14
View File
@@ -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.072051 */
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 -9/。/./,— rejecting just the dot turned a typed
// "0。05" into "005", a 100× amount error, so map instead of reject
.replace(/[-]/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
+16 -123
View File
@@ -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<KyberRouteData> {
// base arg makes path-relative bases (/kyber proxy mode) work — new URL()
// throws on a bare relative path; the base is ignored for absolute URLs
const u = new URL(`${api()}/routes`, location.origin)
u.searchParams.set('tokenIn', tokenIn)
u.searchParams.set('tokenOut', tokenOut)
u.searchParams.set('amountIn', amountIn.toString())
u.searchParams.set('gasInclude', 'true')
// optional platform fee (charged on output token, encoded by the kyber router;
// verified working on this chain — see README)
if ((opts?.applyFee ?? true) && ENV.kyberFeeBps > 0 && ENV.kyberFeeReceiver) {
u.searchParams.set('feeAmount', String(ENV.kyberFeeBps))
u.searchParams.set('chargeFeeBy', 'currency_out')
u.searchParams.set('isInBps', 'true')
u.searchParams.set('feeReceiver', ENV.kyberFeeReceiver)
}
const r = await fetch(u, { headers: HEADERS, signal: opts?.signal })
const j = await r.json().catch(() => null)
if (!r.ok || !j || j.code !== 0 || !j.data?.routeSummary) {
throw new Error(`kyber routes failed: ${j?.message ?? r.status}`)
}
return j.data as KyberRouteData
}
signal?: AbortSignal,
): Promise<number> {
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<KyberBuildData> {
const r = await fetch(`${api()}/route/build`, {
method: 'POST',
headers: { ...HEADERS, 'content-type': 'application/json' },
body: JSON.stringify({
routeSummary,
sender,
recipient,
slippageTolerance: slippageBps,
source: 'up33-terminal',
enableGasEstimation: false,
}),
})
const j = await r.json().catch(() => null)
if (!r.ok || !j || j.code !== 0 || !j.data?.data) {
throw new Error(`kyber build failed: ${j?.message ?? r.status}`)
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<KyberToken[]> {
const base = ENV.proxied ? '/kyber-setting' : 'https://ks-setting.kyberswap.com'
const out: KyberToken[] = []
for (let page = 1; page <= 3; page++) {
const r = await fetch(`${base}/api/v1/tokens?chainIds=4663&pageSize=100&page=${page}`)
const j = await r.json().catch(() => null)
const toks: any[] = j?.data?.tokens ?? []
for (const t of toks) {
if (t?.address && t?.symbol && Number.isFinite(t?.decimals)) {
out.push({ address: t.address as Address, symbol: t.symbol, decimals: t.decimals })
}
}
if (toks.length < 100) break
}
return out
}
/** flatten route into "55% up-v3 · 45% uniswapv3" style breakdown */
export function routeBreakdown(rs: KyberRouteSummary): string {
const amountIn = BigInt(rs.amountIn || '0')
if (amountIn === 0n || !rs.route?.length) return ''
const parts: string[] = []
for (const path of rs.route) {
if (!path.length) continue
const first = path[0]
const pctNum = Number((BigInt(first.swapAmount || '0') * 1000n) / amountIn) / 10
const names = [...new Set(path.map((h) => h.exchange))].join('→')
parts.push(`${pctNum}% ${names}`)
}
return parts.join(' · ')
return value
}
-48
View File
@@ -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<GatedKyberTx> {
const built = await kyberBuild(args.routeSummary, args.sender, args.recipient, args.slippageBps)
if (getAddress(built.routerAddress) !== ENV.kyberRouter) {
throw new Error(t('kyber.routerMismatch', { addr: built.routerAddress }))
}
const value = BigInt(built.transactionValue ?? '0')
const expected = args.nativeIn ? args.amountIn : 0n
if (value !== expected) {
throw new Error(t('kyber.badValue', { got: value.toString(), want: expected.toString() }))
}
const quotedOut = BigInt(args.routeSummary.amountOut)
if (
BigInt(built.amountIn) !== args.amountIn ||
BigInt(built.amountOut) < applySlippage(quotedOut, args.slippageBps)
) {
throw new Error(t('kyber.buildDeviates', { in: built.amountIn, out: built.amountOut }))
}
return { to: ENV.kyberRouter, data: built.data, value }
}
+1 -1
View File
@@ -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
+51
View File
@@ -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<string>()
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`)
}
}
})
+25
View File
@@ -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
}
+131
View File
@@ -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<string, string>()
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
}
})
+262
View File
@@ -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<string, unknown>
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)
},
}
+41
View File
@@ -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}`)
}
})
+22
View File
@@ -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)
}
+18
View File
@@ -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])
})
+16
View File
@@ -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
+11
View File
@@ -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 次后暂停/)
})
+153
View File
@@ -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<SolverQuote> {
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<string, string> = {
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(' + ')
}
+35
View File
@@ -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)
})
+23
View File
@@ -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<bigint>
}
/** 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<bigint> {
const estimated = await client.estimateGas({ account, to: tx.to, data: tx.data, value: tx.value })
return (estimated * 6n + 4n) / 5n
}
+119
View File
@@ -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<number>((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()
}
})
+70
View File
@@ -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<object, Promise<unknown>>()
run<T>(query: object, refetch: () => Promise<T>): Promise<T> {
const active = this.inFlight.get(query)
if (active) return active as Promise<T>
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 = <Solver, Direct>({
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,
}
}
+22
View File
@@ -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/)
}
})
+16
View File
@@ -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)
}
+97
View File
@@ -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<boolean> {
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<boolean> {
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
}
+218
View File
@@ -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<PreparedSwap> {
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<ConfirmedSwap | null> {
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<ConfirmedSwap | null> {
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 } }
}
+135
View File
@@ -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
})
+82
View File
@@ -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
}
+31
View File
@@ -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<void>((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)
})
+47
View File
@@ -0,0 +1,47 @@
import type { Address } from 'viem'
const active = new Set<string>()
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<unknown>): Promise<boolean> {
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)
},
}
+80
View File
@@ -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<TransactionReceipt> => 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')
})
+107 -25
View File
@@ -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<Hex>,
opts?: { onSuccess?: (rcpt: TransactionReceipt) => void },
): Promise<Hex | null> {
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<TransactionReceipt | null> {
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<boolean> {
): Promise<AllowanceResult | null> {
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 {
+3
View File
@@ -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
}
+3 -2
View File
@@ -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'
+40
View File
@@ -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<string, unknown> | undefined
const client = {
simulateContract: async (next: Record<string, unknown>) => {
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])
})
+21
View File
@@ -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<readonly [bigint, bigint]> {
try {
const sim = await pc.simulateContract({
abi: v2PoolAbi,
address: pool,
functionName: 'claimFees',
account: user,
})
return sim.result as readonly [bigint, bigint]
} catch {
return fallback
}
}
+368 -181
View File
@@ -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 (02) */
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<ZapTarget, { kind: 'cl-mint' | 'cl-increase' }>
const { tickLower, tickUpper } = target as Extract<ZapTarget, { kind: 'cl-mint' | 'cl-increase' }>
const p = Number(pool.sqrtPriceX96) / Number(Q96)
const a = Number(getSqrtRatioAtTick(tickLower)) / Number(Q96)
const b = Number(getSqrtRatioAtTick(tickUpper)) / Number(Q96)
@@ -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: 12 kyber quotes.
* (no route, empty pool, dust amounts). Network: 12 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<SwapLegQuote> {
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<ZapPlan> {
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<ZapLeg[]> =>
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<string>()
for (const path of r.routeSummary.route ?? []) for (const h of path) names.add(h.exchange)
return [...names].slice(0, 3).join(' · ')
}
// ---------------- stages ----------------
export type ZapStageKind = 'wrap' | 'approveIn' | 'swap' | 'approve0' | 'approve1' | 'deposit'
export type ZapStage = { kind: ZapStageKind; label: string }
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<ZapRun> {
const { plan, target, user, slipBps, t0, t1 } = args
const pool = poolOf(target)
const stages = zapStages(plan, target, t0, t1)
const tIn = plan.inIs0 ? t0 : t1
const otherErc20 = plan.inIs0 ? pool.token1 : pool.token0
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<boolean> {
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<boolean> {
onFail?: (why: StepFailWhy) => void,
): Promise<TransactionReceipt | null> {
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
}
+63
View File
@@ -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)/(As) 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)
}
})
+47
View File
@@ -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
}
+1 -1
View File
@@ -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<string, unknown>).__viemCcipEagerPin = ccipRequest
+809 -40
View File
@@ -136,6 +136,11 @@
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
/* RainbowKitProvider wraps the app in an unstyled <div data-rk> 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 <button> would otherwise impose and
takes only the affordance: a pointer and an accent on hover. */
.pair-btn {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
letter-spacing: inherit;
text-align: left;
cursor: pointer;
/* the affordance. text-decoration rather than border-bottom so the rule
tracks the glyphs instead of the button box the pair cell ellipsizes on
a phone, and a border would keep drawing under the empty tail. --dim is
the palette's legible-but-secondary token (the same weight as the type
line under the name), so the rule reads without competing with it. */
text-decoration: underline dashed var(--dim);
text-underline-offset: 3px;
text-decoration-thickness: 1px;
}
/* Callers style the trigger with their own class `pair-name` for the pools
table's old <b>, `card-title` on the positions cards. Both are declared
EARLIER in this file, so the `font: inherit` above would otherwise strip
their weight (and card-title its color) purely on source order. Restating
them at .pair-btn.X specificity is what keeps the two look identical to the
<span>/<b> each replaced. */
.pair-btn.pair-name,
.pair-btn.card-title { font-weight: 700; }
.pair-btn.card-title { color: var(--fg); }
/* currentColor, not a second token: the rule then brightens with the label in
every theme instead of needing its own accent that could come out darker */
.pair-btn:hover,
.pair-btn.on { color: var(--acc); text-decoration-color: currentColor; }
/* The pools table's identity line: name + protocol mark + DS jump. Flex so the
NAME is what shrinks and ellipsizes under the mobile width clamp further
down laid out inline, the marks sit after the name and were the first
things the clamp cut off. text-overflow has to move onto the name with it:
a flex container never ellipsizes its items. */
.pair-line { display: flex; align-items: center; gap: 6px; min-width: 0; }
.pair-line > .pair-btn {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* the ellipsis needs overflow:hidden, and overflow clips at the PADDING box
which the dashed rule, sitting 3px under a 16px line box, falls outside of.
2px buys it back and costs nothing: row height measures 45.7px either way. */
padding-bottom: 2px;
}
.pair-line > :not(.pair-btn) { flex: 0 0 auto; }
.pair-line .proto-mini { margin-left: 0; } /* the gap above already spaces it */
.addr-pop {
position: fixed;
z-index: 70;
width: min(320px, calc(100vw - 16px));
background: var(--inset);
border: 1px solid var(--acc-dim);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
padding: 8px 10px;
text-align: left;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
font-size: 0.92em;
}
.addr-pop-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
padding-bottom: 5px;
border-bottom: 1px solid var(--line);
}
.addr-pop-head b { color: var(--fg); font-weight: 700; }
.addr-pop-head .dim { font-size: 0.85em; margin-left: auto; }
.addr-row { display: flex; align-items: center; gap: 6px; }
/* the ticker column is fixed-width so the three addresses line up, but a long
symbol must ellipsize rather than push the address out of the card */
.addr-k {
color: var(--dim);
flex: 0 0 6.5ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.addr-copy {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
background: none;
border: 1px solid transparent;
color: var(--fg);
font: inherit;
cursor: pointer;
padding: 3px 5px;
}
.addr-copy:hover { border-color: var(--line-hi); background: var(--panel2); }
.addr-v { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.addr-ok { color: var(--green); }
.addr-bad { color: var(--red); }
.addr-ext { text-decoration: none; padding: 0 2px; }
.addr-ext:hover { color: var(--fg); }
.footer {
display: flex;
gap: 14px;
@@ -656,7 +1067,8 @@ th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
th.sortable.on { color: var(--acc); }
.mydot { color: var(--acc); margin-right: 4px; }
/* owned-position rows carry a green left rail so they scan out of the list */
.tbl tr.rowhover.is-mine td:first-child { box-shadow: inset 3px 0 0 var(--green); }
/* pools table: compact two-line cells */
.pair-sub {
@@ -709,48 +1121,228 @@ th.sortable.on { color: var(--acc); }
.spec-row .sv { white-space: nowrap; }
.spec-row .sd { color: var(--dim); }
/* swap panel */
.swap-side {
/* swap trade cards — SELL / BUY */
.swap-card {
border: 1px solid var(--line-hi);
background: var(--panel2);
padding: 10px 12px;
padding: 10px 14px 12px;
}
.swap-card:focus-within { border-color: var(--acc-dim); }
.swap-card .hd {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.swap-side .top { display: flex; gap: 12px; align-items: center; }
.swap-side .side-lbl { color: var(--dim); font-size: 0.8em; width: 40px; text-transform: uppercase; letter-spacing: 1px; }
.swap-side .amt {
.swap-card .side-lbl { color: var(--dim); font-size: 0.8em; text-transform: uppercase; letter-spacing: 1.5px; }
.swap-card .bal {
background: none;
border: none;
padding: 0;
color: var(--dim);
font-family: inherit;
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
}
.swap-card .bal:hover { color: var(--fg); }
.swap-card .bal.static { cursor: default; }
.swap-card .bal.static:hover { color: var(--dim); }
.swap-card .bal.red, .swap-card .bal.red:hover { color: var(--red); }
.swap-card .io { display: flex; gap: 12px; align-items: center; }
.swap-card .tsel-btn { background: var(--panel); padding: 7px 12px; font-size: 1.05em; }
.swap-card .amt {
background: none;
border: none;
outline: none;
color: var(--fg);
font-size: 1.5em;
font-size: 2em;
width: 100%;
caret-color: var(--acc);
font-family: inherit;
text-align: right;
min-width: 60px;
padding: 0;
}
.swap-side .amt::placeholder { color: var(--placeholder); }
.swap-side .out {
font-size: 1.5em;
.swap-card .amt::placeholder { color: var(--placeholder); }
.swap-card .out {
font-size: 2em;
flex: 1;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.swap-side .meta {
.swap-card .ft {
display: flex;
justify-content: space-between;
color: var(--dim);
font-size: 0.85em;
align-items: center;
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
gap: 6px;
min-height: 20px;
}
.swap-mid { display: flex; justify-content: center; margin: -4px 0 4px; }
.rate-line { color: var(--dim); font-size: 0.9em; margin: 8px 0 2px; cursor: pointer; }
.rate-line:hover { color: var(--fg); }
.swap-card .ft .pcts { display: flex; gap: 6px; flex-wrap: wrap; }
.swap-card .ft .usd { color: var(--dim); font-size: 0.9em; margin-left: auto; white-space: nowrap; }
.swap-card .ft .usd .delta.red { color: var(--red); }
/* BRIDGE: fixed-asset chip is informational, not a picker kill the
interactive affordances the tsel-btn base carries */
.tsel-btn.static { cursor: default; }
.tsel-btn.static:hover { border-color: var(--line-hi); }
/* BRIDGE: chain endpoint selector on each card (standard from/to bridge model).
Reuses the tsel popover chrome; items are buttons for keyboard access. */
.swap-card .side { display: inline-flex; align-items: center; gap: 8px; }
.chain-sel { position: relative; display: inline-flex; }
.chain-btn {
background: var(--panel);
border: 1px solid var(--line-hi);
color: var(--fg);
font-family: inherit;
font-size: 0.9em;
letter-spacing: 1px;
padding: 3px 9px;
cursor: pointer;
white-space: nowrap;
}
.chain-btn:hover { border-color: var(--acc-dim); background: var(--panel2); }
.chain-btn .caret { color: var(--dim); }
.chain-pop {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
min-width: 190px;
background: var(--inset);
border: 1px solid var(--acc-dim);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
}
.chain-pop .tsel-item {
background: none;
border: none;
width: 100%;
font: inherit;
color: inherit;
letter-spacing: 1px;
}
.chain-pop .tsel-item.cur { color: var(--acc); }
/* BRIDGE: inline execution progress a cross-chain fill takes seconds to
minutes and must be visible without opening the history popover */
.bridge-progress {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
border: 1px solid var(--line);
background: var(--panel);
padding: 8px 12px;
}
.bridge-progress .stg { color: var(--dim); letter-spacing: 1px; }
.bridge-progress .stg.on { color: var(--acc); }
.bridge-progress .stg.done { color: var(--green); }
.bridge-progress .stg.warn { color: var(--amber); }
.bridge-progress .stg.err { color: var(--red); }
.bridge-progress .sep { color: var(--line-hi); }
.bridge-progress .elapsed { margin-left: auto; color: var(--dim); }
/* BRIDGE: persisted pending transfers updated conservatively, so the row
carries its own countdown instead of a live-polling illusion */
.pend-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.pend-row {
display: flex;
align-items: baseline;
gap: 10px;
border: 1px solid var(--line);
background: var(--panel);
padding: 6px 10px;
font-size: 0.85em;
white-space: nowrap;
}
.pend-row .amt { flex-shrink: 0; }
.pend-row .prov { color: var(--dim); letter-spacing: 0.5px; overflow: hidden; text-overflow: ellipsis; }
.pend-row .eta { margin-left: auto; color: var(--dim); }
.pend-row .eta a { color: inherit; }
.pend-row .x {
border: none;
background: none;
color: var(--dim);
cursor: pointer;
font-family: inherit;
font-size: 1em;
padding: 0 2px;
}
.pend-row .x:hover { color: var(--fg); }
/* SWAP: persisted in-flight swaps survive reload so a pending swap stays
visible (the corner activity log is in-memory and lost on reload), and the
user isn't tempted to re-fire it. usePendingSwaps' poller flips the state. */
.pending-swaps { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
.pending-row {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
background: var(--panel);
padding: 6px 10px;
font-size: 0.85em;
white-space: nowrap;
}
.pending-row.green { border-color: var(--green-dim); }
.pending-row.red { border-color: var(--red); }
.pending-row.amber { border-color: var(--amber); }
.pending-row .ic { min-width: 12px; text-align: center; }
.pending-row.green .ic { color: var(--green); }
.pending-row.red .ic { color: var(--red); }
.pending-row.amber .ic { color: var(--amber); }
.pending-row .pair { color: var(--fg); overflow: hidden; text-overflow: ellipsis; }
.pending-row .st { color: var(--dim); letter-spacing: 0.5px; }
.pending-row .view { margin-left: auto; color: var(--acc); text-decoration: none; }
.pending-row .view:hover { text-decoration: underline; }
.pending-row .x {
border: none;
background: none;
color: var(--dim);
cursor: pointer;
font-family: inherit;
font-size: 1em;
padding: 0 2px;
}
.pending-row .x:hover { color: var(--fg); }
/* direction flip — overlaps both cards */
.swap-flip-row { display: flex; justify-content: center; margin: -11px 0; position: relative; z-index: 4; }
.swap-flip {
width: 38px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: var(--panel);
border: 1px solid var(--line-hi);
color: var(--acc);
cursor: pointer;
font-family: inherit;
font-size: 1em;
}
.swap-flip:hover { border-color: var(--acc-dim); background: var(--panel2); }
/* DETAILS — receipt-style key/value rows with dotted leaders */
.kv-list { margin: 2px 0 4px; }
.kv { display: flex; align-items: baseline; gap: 8px; padding: 3px 0; }
.kv .k { color: var(--dim); text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; flex-shrink: 0; }
.kv .fill { flex: 1; border-bottom: 1px dotted var(--line); transform: translateY(-3px); min-width: 20px; }
.kv .v { text-align: right; }
.kv.click { cursor: pointer; }
.kv.click:hover .k { color: var(--fg); }
/* indented breakdown rows under an expandable kv (e.g. IMPACT -> fees) */
.kv.sub { padding-left: 16px; }
.kv-sub { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; padding: 4px 0 6px; }
/* full-width action button */
.swap-cta { margin-top: 12px; }
.swap-cta .btn { width: 100%; padding: 12px 16px; font-size: 1.05em; letter-spacing: 1.5px; }
.logaction {
color: var(--acc);
@@ -776,6 +1368,183 @@ th.sortable.on { color: var(--acc); }
.zstep.run .zm { animation: blink 1s step-end infinite; }
.zstep.fail { color: var(--red); }
/* positions: per-card value + earning strip */
.pos-earn { margin-top: 2px; }
.pos-earn .k { color: var(--dim); margin-right: 8px; text-transform: uppercase; font-size: 0.9em; letter-spacing: 0.4px; }
/* ---------- POSITIONS: same-pool aggregation (ClPoolGroup) ---------- */
.pool-group { margin-bottom: 10px; }
.pool-group-head {
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid var(--line);
background: var(--panel2);
padding: 8px 12px;
cursor: pointer;
}
.pool-group-head:hover { background: var(--panel); }
/* open head connects visually to the body's tree guide below it */
.pool-group-head.is-open { border-bottom-color: var(--line-hi); }
.pg-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.pg-caret { color: var(--dim); width: 1ch; display: inline-block; }
.pg-agg { margin: 0; }
/* nested position cards hang off a tree guide, indented under the header */
.pool-group-body {
margin-left: 11px;
border-left: 1px solid var(--line-hi);
padding: 8px 0 0 12px;
}
.pool-group-body .card:last-child { margin-bottom: 4px; }
/* mobile-only content (compact variants of desktop cells) */
.show-m { display: none; }
/* ---------- tablet / small laptop (1023px) ----------
A table's width is set by its widest cell, and this one's widest cells have
no natural bound: a pair label, a reserve line ("24.9M CASHCAT + 12.4 WETH"),
a USD figure. Below a laptop that arithmetic stopped fitting and the columns
that matter most TVL and VOL 24H were the ones pushed off the right edge,
because they sit AFTER the widest column. So the widest column goes first. */
@media (max-width: 1023px) {
.hide-t { display: none !important; }
/* one long symbol must not be able to spend the whole row's width */
.tbl td:first-child > div:not(.expander),
.tbl th:first-child {
max-width: 42vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* ---------- mobile (720px) ----------
one breakpoint: phones + narrow tablets. desktop layout is untouched. */
@media (max-width: 720px) {
/* generic escape hatch: anything tagged hide-m disappears on mobile */
.hide-m { display: none !important; }
.show-m { display: inline; }
.cell-sub.show-m { display: block; } /* stacked second metric in a cell */
/* bottom padding clears the fixed tab bar */
.app { padding: 6px 8px calc(58px + env(safe-area-inset-bottom)); }
.main { padding: 12px 10px; }
/* header keeps brand + history + news + lang + connect on one row. It is
tight: at 360px the English CONNECT leaves 322px for 335px of buttons, so
the wordmark contracts (Header.tsx) and these two densities buy the rest
measure before adding a sixth control here. */
.hdr { gap: 6px; padding: 7px 10px; }
.hist-btn { padding: 4px 7px; }
.hdr-meta { display: none; } /* block number is a desktop nicety */
/* pushes the utility cluster right only the FIRST button in it may take the
auto margin, otherwise the free space splits between them and the cluster
scatters across the row */
.hdr .hist { margin-left: auto; }
.hdr .hist + .hist { margin-left: 0; }
.hdr .theme-ctl > .dim { display: none; } /* "lang:" label */
/* Popovers span the header instead of hanging off their own button. At
88vw a card whose right edge is pinned to a right-aligned button puts its
left half off-screen. Making .hist static hands the containing block to
.hdr, so top:100% still reads as "just under the header" however it wraps. */
.hdr { position: relative; }
.hdr .hist { position: static; }
.hist-pop { left: 0; right: 0; width: auto; }
/* the tab bar drops to the bottom edge — thumb territory */
.tabs {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 60;
gap: 6px;
background: var(--panel);
border-top: 1px solid var(--line-hi);
/* a sliver of air under the buttons — they shouldn't kiss the screen edge */
padding: 7px 10px calc(13px + env(safe-area-inset-bottom));
}
.tab { flex: 1; padding: 8px 2px; text-align: center; }
.tab .key { display: none; } /* [1]/[2]/[3] are keyboard affordances */
/* fixed 110px form labels waste a third of a phone row */
.form-row .lbl { width: auto; }
.form-row { row-gap: 6px; }
/* no rpc/theme/blockscout chrome on a phone — the bottom bar owns that edge */
.footer { display: none; }
.tbl { font-size: 0.9em; }
.tbl th,
.tbl td { padding: 8px 6px; } /* taller rows breathe on a phone */
/* A phone overlays its scrollbars, so the reserved gutter draws nothing it
just takes 8px off the right of the ONE block that reserves it. The table
then stops short of the search box and the chips above it while its left
edge stays flush, and the pair column pays for it. `auto` gives the space
back; where a scrollbar really does take room it is still visible there,
which reads as a scrollbar rather than as dead margin. */
.tbl-wrap { scrollbar-gutter: auto; }
/* The three phone columns are a budget, not a suggestion.
`overflow-x: hidden` says the table may not scroll sideways; fixed layout
is what makes that promise keepable, by handing every column its width up
front instead of deriving it from the longest value that happens to be on
screen. Cells then ellipsize rather than push one bad number (a runaway
TVL, a 30-character symbol) can no longer move the columns next to it. */
.tbl-wrap { overflow-x: hidden; }
.tbl { table-layout: fixed; }
/* col 1 pair · col 3 TVL (+VOL 24H stacked) · col 6 FEE APR (+REWARDS)
the rest are display:none here. Percentages so the split survives a 320px
phone, where fixed pixels would starve the pair column.
All three are declared, and the three sum to 100%. Naming the pair column
is not cosmetic: an expanded row is a single <td colSpan={8}>, and a
colspan wider than the table's column count CREATES the missing columns.
With 49% left unclaimed the fixed algorithm split it equally between those
five phantom columns, so opening a panel shrank the pair column of every
row above it to 8% (measured at 720px: 334px -> 56px). Leaving nothing to
divide is what holds the rows still. :not([colspan]) keeps the width off
the panel cell itself, which must span the lot. */
.tbl th:nth-child(1),
.tbl td:nth-child(1):not([colspan]) { width: 49%; }
.tbl th:nth-child(3),
.tbl td:nth-child(3) { width: 27%; }
.tbl th:nth-child(6),
.tbl td:nth-child(6) { width: 24%; }
.tbl tbody .num,
.tbl tbody .num .cell-sub {
overflow: hidden;
text-overflow: ellipsis;
}
/* Headers wrap instead of clipping a truncated "FEE APR" would take the
sort arrow with it, and that arrow is the only cue the column is sortable.
`anywhere` rather than plain wrapping so a header with no space in it (a
long localization, an unbreakable glyph run) still cannot poke out.
`.tbl thead .num` is deliberate, not redundant: plain `.tbl thead th` loses
to `.tbl .num`'s nowrap on specificity (0,1,2 vs 0,2,0), so the headers
that most need to wrap the numeric ones were the only ones that
couldn't. Adding the thead-scoped variant (0,2,1) is what outranks it. */
.tbl thead th,
.tbl thead .num {
white-space: normal;
overflow-wrap: anywhere;
}
/* spec rows (ZAP PLAN / LIMIT ticket) stack on a phone the three-column
grid starves the detail column into a one-character-per-line sliver */
.spec-row {
grid-template-columns: 1fr;
gap: 2px;
padding: 6px 10px;
}
.spec-row .sv { white-space: normal; }
/* ellipsize the pair cell's tail; the full label lives in the expanded panel.
:not(.expander) the ADD LP row is one full-width td whose first div is
the panel itself; clamping it squeezed the whole form to ~150px.
The width now comes from the fixed layout above, so this is a plain 100%
rather than the old `min(168px, calc(100vw - 246px))` guess that number
had to predict what the metric columns would need, and could only ever be
wrong in one of the two directions. */
.tbl td:first-child > div:not(.expander) {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
-2
View File
@@ -4,8 +4,6 @@ interface ImportMetaEnv {
readonly RPC?: string
readonly KYBERSWAP_AGGREGATOR_API_BASE_URL?: string
readonly KYBERSWAP_CHAIN?: string
readonly KYBERSWAP_ROUTER_ADDRESS?: string
readonly KYBERSWAP_FEE_BPS?: string
readonly KYBERSWAP_FEE_RECEIVER?: string
readonly VITE_WALLETCONNECT_PROJECT_ID?: string
}