mirror of
https://github.com/labrinyang/lp-terminal.git
synced 2026-07-27 13:17:43 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
+837
-361
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Bridge domain config: which remote chains the BRIDGE tab offers. Robinhood
|
||||
// Chain is always one side of every transfer; a "remote" is the other. WHICH
|
||||
// tokens can travel is NOT configured here — it is discovered live from each
|
||||
// engine's own support surface (lib/bridge/tokens.ts), same-token routes only.
|
||||
// Facts (routes, costs, provider behavior) are documented in docs/bridge-research.md.
|
||||
import type { Address, Chain } from 'viem'
|
||||
import { arbitrum, base, mainnet, optimism } from 'wagmi/chains'
|
||||
import { CHAIN_ID, EXPLORER } from './addresses'
|
||||
import type { BridgeProviderId } from '../lib/bridge/types'
|
||||
|
||||
/** zero address = native-currency sentinel understood by Relay, Across and the canonical bridge */
|
||||
export const NATIVE_SENTINEL = '0x0000000000000000000000000000000000000000' as Address
|
||||
|
||||
export type RemoteChain = {
|
||||
chain: Chain
|
||||
label: string
|
||||
}
|
||||
|
||||
export const REMOTE_CHAINS: RemoteChain[] = [
|
||||
{ chain: mainnet, label: 'ETHEREUM' },
|
||||
{ chain: arbitrum, label: 'ARBITRUM' },
|
||||
{ chain: base, label: 'BASE' },
|
||||
{ chain: optimism, label: 'OPTIMISM' },
|
||||
]
|
||||
|
||||
export function remoteById(id: number): RemoteChain | null {
|
||||
return REMOTE_CHAINS.find((r) => r.chain.id === id) ?? null
|
||||
}
|
||||
|
||||
export function explorerOf(chainId: number): string {
|
||||
if (chainId === CHAIN_ID) return EXPLORER
|
||||
return remoteById(chainId)?.chain.blockExplorers?.default.url ?? EXPLORER
|
||||
}
|
||||
|
||||
/** 'in' = deposit onto Robinhood, 'out' = withdraw from Robinhood */
|
||||
export type BridgeDir = 'in' | 'out'
|
||||
|
||||
/** one discovered same-token route between Robinhood and the selected remote.
|
||||
* `providers` is already resolved for the direction the UI is showing. */
|
||||
export type BridgeTokenOption = {
|
||||
symbol: string
|
||||
/** identical on both sides — discovery drops mismatched-decimals pairs */
|
||||
decimals: number
|
||||
/** address on the Robinhood side (NATIVE_SENTINEL = native ETH) */
|
||||
robinhoodToken: Address
|
||||
/** address on the remote side (NATIVE_SENTINEL = native ETH) */
|
||||
remoteToken: Address
|
||||
providers: BridgeProviderId[]
|
||||
}
|
||||
|
||||
export type BridgeIntent = {
|
||||
dir: BridgeDir
|
||||
token: BridgeTokenOption
|
||||
remote: RemoteChain
|
||||
/** origin-side amount in origin token units */
|
||||
amount: bigint
|
||||
}
|
||||
|
||||
export type ResolvedIntent = {
|
||||
originChainId: number
|
||||
destChainId: number
|
||||
inputToken: Address // NATIVE_SENTINEL for native ETH
|
||||
outputToken: Address
|
||||
inputSymbol: string
|
||||
outputSymbol: string
|
||||
inputDecimals: number
|
||||
outputDecimals: number
|
||||
}
|
||||
|
||||
/** map a user intent onto the provider-facing origin/destination legs
|
||||
* (same-token model: both legs share symbol and decimals) */
|
||||
export function resolveIntent(i: BridgeIntent): ResolvedIntent {
|
||||
const remoteId = i.remote.chain.id
|
||||
return {
|
||||
originChainId: i.dir === 'in' ? remoteId : CHAIN_ID,
|
||||
destChainId: i.dir === 'in' ? CHAIN_ID : remoteId,
|
||||
inputToken: i.dir === 'in' ? i.token.remoteToken : i.token.robinhoodToken,
|
||||
outputToken: i.dir === 'in' ? i.token.robinhoodToken : i.token.remoteToken,
|
||||
inputSymbol: i.token.symbol,
|
||||
outputSymbol: i.token.symbol,
|
||||
inputDecimals: i.token.decimals,
|
||||
outputDecimals: i.token.decimals,
|
||||
}
|
||||
}
|
||||
|
||||
/** Robinhood Chain's canonical (Arbitrum) bridge Inbox on Ethereum L1.
|
||||
* Source: l2beat discovery, cross-checked on-chain 2026-07-18:
|
||||
* Inbox.bridge() == 0xDf8755334ce7A73cCF6b581C02eA649AE3E864b3 (the chain's
|
||||
* Bridge, which emitted every verified real deposit's MessageDelivered). */
|
||||
export const PORTAL_INBOX = '0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D' as Address
|
||||
export const PORTAL_PARENT_CHAIN_ID: number = mainnet.id
|
||||
|
||||
/** measured delivery latency of real depositEth transfers (484–689s over 3
|
||||
* samples, 2026-07-18) — quote ~10 min, not the nominal "5 min" */
|
||||
export const PORTAL_ETA_SEC = 600
|
||||
|
||||
+49
-13
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -10,26 +10,39 @@ export const zh: typeof en = {
|
||||
exceedsBalance: '超出余额',
|
||||
bal: '余额',
|
||||
max: 'MAX',
|
||||
maxGasTip: 'MAX 会保留 {{amt}} {{sym}} 作源链 gas — 25/50/75% 按原始余额计算',
|
||||
slipCustom: '自定义',
|
||||
slipInvalid: '需大于 0',
|
||||
close: '× 收起',
|
||||
noMatch: '无匹配',
|
||||
gasToken: '(gas)',
|
||||
tokenSearch: '符号或地址…',
|
||||
tokenResolving: '读取代币中…',
|
||||
tokenNotErc20: '不是本链的 ERC-20',
|
||||
},
|
||||
hdr: {
|
||||
pools: '池子',
|
||||
positions: '仓位',
|
||||
swap: '兑换',
|
||||
epoch: 'epoch',
|
||||
flip: '翻转',
|
||||
bridge: '跨链',
|
||||
blk: '块',
|
||||
history: '历史',
|
||||
historyTip: '活动与交易日志',
|
||||
connect: '[ 连接 ]',
|
||||
wrongChain: '[ 链错误 ]',
|
||||
},
|
||||
news: {
|
||||
label: '更新',
|
||||
title: '最近更新',
|
||||
tip: '更新内容 — 本终端的最近改动',
|
||||
freshTip: '最近两天有 {{n}} 条更新',
|
||||
empty: '暂无更新',
|
||||
},
|
||||
app: {
|
||||
wrongNetwork: '!! 网络错误 — 本终端只在 Robinhood Chain (4663) 上写入',
|
||||
switch: '切换',
|
||||
tagline: 'LP TERMINAL v0.2 · 精确授权 · 链上实时读取',
|
||||
keys: '快捷键: [1] 池子 [2] 仓位 [3] 兑换 [4] 限价',
|
||||
keys: '快捷键: [1] 池子 [2] 仓位 [3] 兑换 [4] 限价 [5] 跨链',
|
||||
blockscout: 'blockscout↗',
|
||||
reloaded: '已刷新 — 此标签页打开期间部署了新版本',
|
||||
},
|
||||
@@ -52,7 +65,8 @@ export const zh: typeof en = {
|
||||
},
|
||||
lang: { label: 'lang:' },
|
||||
rbar: {
|
||||
inRange: '区间内 · 已穿过 {{pct}}% · 带宽 ±{{band}}%',
|
||||
inRange: '区间内',
|
||||
inRangeTip: '已穿过 {{pct}}% · 带宽 ±{{band}}%',
|
||||
outRise: '超出区间 · 价格需上涨 {{pct}}% 才能回到区间',
|
||||
outFall: '超出区间 · 价格需下跌 {{pct}}% 才能回到区间',
|
||||
orderFilled: '订单已完全成交 · 100% 持有 {{sym}} — 提取以锁定',
|
||||
@@ -61,6 +75,7 @@ export const zh: typeof en = {
|
||||
orderWaitFall: '订单等待中 · 价格需下跌 {{pct}}% 开始成交',
|
||||
toLow: '→ 下界',
|
||||
fromHigh: '上界 ←',
|
||||
allOf: '100% {{sym}}',
|
||||
flipTip: '翻转价格方向',
|
||||
ticksTip: 'tick [{{lo}}, {{hi}}] · 当前 {{tick}}',
|
||||
},
|
||||
@@ -72,18 +87,13 @@ export const zh: typeof en = {
|
||||
approve: '授权 {{sym}}',
|
||||
received: '到账 {{amt}} UP',
|
||||
swapToEth: '兑换 → ETH',
|
||||
hintKyberMinOut: '触发 kyber 最小到账保护 — 报价到发送之间价格变了;调高滑点或重试',
|
||||
hintSwapMinOut: '触发最小到账保护 — 报价到发送之间价格变了;调高滑点或重试',
|
||||
hintPS: '价格滑点 — 池价超出容差,重试即可',
|
||||
hintV2Ratio: 'v2 池比例在联动后变了 — 重新输入金额再试',
|
||||
hintSTF: '代币转账失败 — 检查授权/余额',
|
||||
hintNP: '未获此 token id 的授权',
|
||||
hintDeadline: '超过截止时间 — 重试',
|
||||
},
|
||||
kyber: {
|
||||
routerMismatch: '路由地址不符 {{addr}} — 已中止',
|
||||
badValue: '交易 value 异常 {{got}}(应为 {{want}})— 已中止',
|
||||
buildDeviates: '构建结果偏离报价(in {{in}}, out {{out}})— 已中止',
|
||||
},
|
||||
pools: {
|
||||
searchPlaceholder: '搜索交易对 / 符号 / 代币 0x… / 池 0x… ( / )',
|
||||
protoAll: '全部',
|
||||
@@ -114,6 +124,14 @@ export const zh: typeof en = {
|
||||
thFeeApr: '费率 APR',
|
||||
thRewards: '排放奖励',
|
||||
sortTip: '点击排序',
|
||||
addrTip: '地址 — 点击复制',
|
||||
addrHint: '点击复制',
|
||||
addrPool: '池子',
|
||||
addrCopyTip: '复制完整地址',
|
||||
addrCopied: '已复制',
|
||||
addrFailed: '复制失败',
|
||||
addrExplorerTip: '在浏览器中打开',
|
||||
dsTip: '在 dexscreener 打开该交易对(新标签页)',
|
||||
mineDotTip: '你在此池有仓位',
|
||||
gauge: 'gauge',
|
||||
killed: '已停用',
|
||||
@@ -140,6 +158,8 @@ export const zh: typeof en = {
|
||||
fundZapTip: '只持有一边?zap 先把合适的份额换成另一种代币,再存入 — 逐步执行',
|
||||
range: '区间',
|
||||
full: '全区间',
|
||||
more: '更多',
|
||||
moreTip: '单边区间、自定义 %、精确价格或原始 tick',
|
||||
pctCustom: '± % …',
|
||||
pctCustomTip: '对称区间,自定义百分比',
|
||||
above: '↑ 上方',
|
||||
@@ -180,15 +200,19 @@ export const zh: typeof en = {
|
||||
stepAddV2: '添加 v2 {{pair}} 流动性',
|
||||
stepAdd: '添加 {{pair}} 流动性',
|
||||
stepMint: '铸造 {{kind}} {{pair}} [{{lo}},{{hi}}]',
|
||||
stakeAfter: '添加后质押',
|
||||
stakeAfterTip: '添加后直接继续质押新仓位以赚取 UP —— 该选择会被记住,直到你更改',
|
||||
},
|
||||
zap: {
|
||||
zapIn: 'ZAP 输入',
|
||||
ethTip: '原生 ETH — 第 1 步先包装成 WETH',
|
||||
slip: '滑点',
|
||||
slipHint: '兑换腿最小到账 · 存入最小值按 1% 带边计算',
|
||||
solving: '> 正在通过 kyber 求解拆分',
|
||||
solving: '> 正在比较 Uniswap / UP33 直连报价',
|
||||
cantPlan: '> 无法规划: {{err}}',
|
||||
planTitle: 'ZAP 计划',
|
||||
refreshPlan: '刷新计划',
|
||||
refreshTip: '计划每 {{s}} 秒自动刷新,{{n}} 次后暂停 — 点击立即刷新',
|
||||
split: '拆分',
|
||||
keepAll: '全部保留 {{amt}} {{sym}}',
|
||||
keepSwap: '保留 {{keep}} + 兑换 {{swap}} {{sym}}',
|
||||
@@ -197,16 +221,29 @@ export const zh: typeof en = {
|
||||
swapRow: '兑换',
|
||||
swapMin: '最少 {{amt}} @ {{slip}}%',
|
||||
impact: '价格影响 {{pct}}%',
|
||||
impactOff: '价格影响不可用(美元标价不可靠 — 以最小到账为准)',
|
||||
impactOff: '价格影响不可用 — 请选择滑点',
|
||||
chooseSlippage: '执行前请选择滑点',
|
||||
// 不用「手续费」:本 catalog 里它 15 处全指池子手续费,同面板下方就是
|
||||
// 「费率 APR」——裸写「手续费」会被读成池子费,等于没披露
|
||||
terminalFee: '终端费 {{pct}}%',
|
||||
via: '经 {{route}}',
|
||||
pickOther: '其他',
|
||||
swapN: '兑换 {{n}}',
|
||||
outsideSplit: '兑换 {{a0}} {{sym}} → {{sym0}} + {{a1}} {{sym}} → {{sym1}}',
|
||||
outsideOne: '全部 {{amt}} {{sym}} 兑换为 {{outSym}}',
|
||||
outsideSd: '输入币不在交易对内 — 两侧均按存入比例买入',
|
||||
depositRow: '存入',
|
||||
dustSd: '预计零头 {{dust}} 留在钱包(不会发送)',
|
||||
actualSd: '兑换侧按实际到账存入,不按报价',
|
||||
bandWarn: '兑换影响 {{impact}}% 相对 ±{{band}}% 区间过大 — 会产生零头;建议放宽区间或减少数量',
|
||||
run: '⚡ 执行 ZAP',
|
||||
runTx: '⚡ 执行 ZAP({{n}} 笔交易)',
|
||||
runSteps: '⚡ 执行 ZAP({{n}} 步)',
|
||||
runHint: '任一步失败即停 — 资金都在你钱包里,不会悬空',
|
||||
halted: '在第 {{n}} 步停止 — 已完成的步骤都是普通钱包余额。解决后重新执行(会按当前余额重新规划),或用双币模式收尾。',
|
||||
haltedSwap:
|
||||
'在第 {{n}} 步(兑换)停止:最新报价已低于 {{slip}}% 滑点下限 — 价格动了。请提高滑点以增加执行成功率(AUTO 已上调为 {{next}}%),然后重新执行 — 会按当前余额重新规划。',
|
||||
haltedDeposit:
|
||||
'在第 {{n}} 步(存入)停止:交易在途时池价越过了 {{band}}% 存入容差。直接重试即可 — 最小值按实时价格重算;已兑换的代币安全在钱包。',
|
||||
done: '✓ zap 完成 — 流动性已生效。',
|
||||
replanFailed: 'zap 重新规划失败: {{err}}',
|
||||
orderGrows: '这会加大未平的区间订单 — 同区间、同均价、更大规模。',
|
||||
@@ -214,11 +251,11 @@ export const zh: typeof en = {
|
||||
errNotInPool: '该代币不在此池中',
|
||||
errNoReserves: '池子无储备 — 用双币模式初始注入',
|
||||
errNoPrice: '池价格不可用',
|
||||
errZeroQuote: 'kyber 报价为零 — 池/路由太薄',
|
||||
errNoRoute: '对手代币没有 kyber 路由',
|
||||
errZeroQuote: '直连报价为零 — 池/路由太薄',
|
||||
errNoRoute: '对手代币没有直连路由',
|
||||
errQuoteFailed: '{{protocols}} 直连报价比较失败',
|
||||
errTooSmall: '数量对该区间来说太小',
|
||||
stWrap: '包装 {{amt}} ETH → WETH',
|
||||
stApproveKyber: '授权 {{sym}} → kyber 路由',
|
||||
stApproveSpender: '授权 {{sym}} → {{spender}}',
|
||||
spenderRouter: '路由',
|
||||
spenderNpm: '仓位管理器',
|
||||
@@ -226,11 +263,9 @@ export const zh: typeof en = {
|
||||
stIncrease: '加仓 #{{id}}',
|
||||
stMint: '铸造仓位',
|
||||
stAddLiquidity: '添加流动性',
|
||||
stStake: '质押仓位 → 赚取 UP',
|
||||
halt: 'zap 已停止 — {{msg}}',
|
||||
haltRequote: '重新报价失败: {{err}}',
|
||||
haltPriceMoved: '价格自预览后变动(现 {{now}},预览 {{prev}})— 请核对后重新执行',
|
||||
haltTokenOut: '路由 tokenOut {{addr}} 不是池子对手代币 — 已中止',
|
||||
haltNoTransfer: '兑换成功但未找到对手代币转账 — 请先排查再重试',
|
||||
haltNothing: '没有可存入的数量',
|
||||
haltDepositSmall: '当前价格下存入量对该区间太小(已换的代币在你钱包里)',
|
||||
stakeHint: 'zap 完成 — 质押仓位开始赚 UP',
|
||||
@@ -247,7 +282,7 @@ export const zh: typeof en = {
|
||||
lpValueSub: '{{cl}} CL · {{v2}} v2 · {{staked}} 已质押',
|
||||
lpValueFees: '未领手续费 ≈{{usd}}',
|
||||
lpValueUnpriced: '{{n}} 个未定价',
|
||||
pendingUp: '待领 UP',
|
||||
pendingRewards: '待领奖励',
|
||||
upPerDay: '+{{n}} UP/天',
|
||||
upPerDayUsd: '(≈{{usd}}/天)',
|
||||
claimAll: '全部领取({{n}} 笔)',
|
||||
@@ -286,20 +321,19 @@ export const zh: typeof en = {
|
||||
withdrawTip: '移除 100% 流动性并领取全部(双击确认)',
|
||||
value: '价值',
|
||||
noAnchor: '— 无价格锚',
|
||||
holds: '持有',
|
||||
pendingUpK: '待领 UP',
|
||||
fees: '未领手续费',
|
||||
levyNote: '(10% 未质押抽成已扣除)',
|
||||
earning: '收益中',
|
||||
earnEmit: '≈ {{n}} UP/天',
|
||||
earnUsdDay: '({{usd}}/天)',
|
||||
earnApr: 'APR ≈ {{apr}}',
|
||||
earnEmit: '{{n}} UP/天',
|
||||
earnUsdDay: '≈ {{usd}}/天',
|
||||
earnApr: 'APR {{apr}}',
|
||||
earnShareStaked: '占质押流动性 {{share}}',
|
||||
earnShareGauge: '占 gauge {{share}}',
|
||||
earnOutStaked: '0 — 超出区间',
|
||||
earnOutStakedTip: '质押 CL 只在价格处于区间内时发 UP',
|
||||
earnEnded: '0 — 本 epoch 无 UP 排放流入',
|
||||
earnFeeApr: '费率 APR ≈',
|
||||
earnFeeApr: '费率 APR',
|
||||
earnShareActive: '占活跃流动性 {{share}}',
|
||||
earnSharePool: '占池子 {{share}}',
|
||||
earnWhileInRange: '区间内有效',
|
||||
@@ -308,6 +342,11 @@ export const zh: typeof en = {
|
||||
earnEmpty: '空仓 — 仅剩未领手续费',
|
||||
earningStaked: '收益中(质押)',
|
||||
earningWallet: '收益中(钱包)',
|
||||
// same-pool aggregation header (ClPoolGroup)
|
||||
groupPositions: '{{n}} 个仓位',
|
||||
groupStakedN: '{{n}} 个质押',
|
||||
groupStakedShare: '质押占比',
|
||||
groupIdle: '当前无收益',
|
||||
orderRow: '区间订单 {{sell}} → {{buy}}:',
|
||||
orderLockIn: '提取 → 锁定 {{sym}}',
|
||||
orderClose: '立即平仓(部分成交)',
|
||||
@@ -336,9 +375,9 @@ export const zh: typeof en = {
|
||||
v2StakeAll: '全部质押',
|
||||
v2UnstakeAll: '全部解押',
|
||||
v2ClaimFees: '领取手续费',
|
||||
v2Total: '合计',
|
||||
v2WalletLp: '钱包 LP',
|
||||
v2StakedLp: '质押 LP',
|
||||
v2Lp: 'LP',
|
||||
v2Staked: '{{n}} 已质押',
|
||||
v2Wallet: '{{n}} 在钱包',
|
||||
v2Claimable: '可领手续费',
|
||||
v2RemoveOf: '按钱包 LP 计',
|
||||
v2RemoveStakedNote: '— 质押的 LP 需先解押',
|
||||
@@ -363,42 +402,67 @@ export const zh: typeof en = {
|
||||
market: '市价',
|
||||
limit: '限价 · 挂 LP 卖出',
|
||||
limitTip: '用单边 LP(CL 区间订单)卖出代币',
|
||||
chainTip: '网络标识 — 本终端的所有报价与交易均在 Robinhood Chain(id {{id}})上执行',
|
||||
loadingTokens: '> 加载代币列表…',
|
||||
from: '卖出',
|
||||
to: '买入',
|
||||
sell: '卖出',
|
||||
buy: '买入',
|
||||
balTip: '钱包余额 — 点击填入最大可用',
|
||||
flipTip: '互换方向',
|
||||
rate: '汇率: 1 {{a}} = {{n}} {{b}}',
|
||||
rateV: '1 {{a}} = {{n}} {{b}}',
|
||||
rateTip: '点击反转',
|
||||
quotes: '报价',
|
||||
enterAmount: '输入数量以获取两路报价。',
|
||||
kyberAgg: 'KYBER 聚合',
|
||||
up33Native: 'UP33 原生',
|
||||
route: '路由',
|
||||
details: '明细',
|
||||
kRate: '汇率',
|
||||
kMinReceived: '最少到账',
|
||||
// 「价格影响」仍是桥接那一行的标签,那里是到账/发送的带符号差值。兑换这边叫
|
||||
// 「成交成本」:price impact 惯例上指不含费的规模冲击,而这个数是刻意把费用
|
||||
// 折进去的,沿用旧词只会继续误导。
|
||||
kImpact: '价格影响',
|
||||
kCost: '成交成本',
|
||||
costTip: '本笔交易的全部成本 — 价格推动、池子手续费、代币转账税与终端手续费合计,相对于「不受规模与费用影响时能拿到的价格」',
|
||||
kTerminalFee: '终端手续费',
|
||||
terminalFeeOfWhich: '我们收取的 {{pct}}% — 已包含在上面的成交成本里,不是额外加收',
|
||||
kFee: '费用',
|
||||
ctaEnterAmount: '输入数量',
|
||||
ctaQuoting: '询价中…',
|
||||
ctaRefreshQuote: '↻ 刷新报价',
|
||||
ctaPending: '交易确认中',
|
||||
pendingSaveFailed: '交易已发送,但待确认状态无法保存 — 请保持此页面开启',
|
||||
pendPending: '待确认',
|
||||
pendConfirmed: '已确认',
|
||||
pendFailed: '失败',
|
||||
pendStale: '未确认',
|
||||
viewTx: '查看',
|
||||
pendingDismiss: '清除此行(不影响链上交易)',
|
||||
refreshTip: '报价每 {{s}} 秒自动刷新 — 点击立即刷新',
|
||||
solverRefreshTip: '报价每 {{s}} 秒刷新;SHEEP CHOICE 自动刷新 3 次后暂停 — 点击立即刷新',
|
||||
usdDeltaTip: '买入侧与卖出侧的美元价值差(按场馆现价)— 已包含价格影响与费用',
|
||||
solverRoute: 'SHEEP CHOICE',
|
||||
solverDown: 'SHEEP CHOICE 离线 — 仅直连场所',
|
||||
stSwapSolver: '兑换 {{amt}} {{a}} → {{b}} [sheep choice]',
|
||||
uniswapDirect: 'UNISWAP 直连',
|
||||
up33Direct: 'UP33 直连',
|
||||
quoteImpact: '影响 {{pct}}%', // 桥接卡片
|
||||
quoteCost: '成本 {{pct}}%',
|
||||
noDirectPool: '该交易对没有直连池',
|
||||
quoteFailed: '直连报价失败',
|
||||
tokenListFallback: 'Uniswap 索引不可用 — 代币列表仅显示已验证的发现结果。',
|
||||
tokenListFailed: 'Uniswap 代币列表失败: {{err}}',
|
||||
up33TokenListFailed: 'UP33 池注册表失败: {{err}}',
|
||||
unavailable: '不可用: {{err}}',
|
||||
gas: 'gas {{usd}}',
|
||||
best: '最优',
|
||||
viaV2: '经 v2 {{kind}} {{fee}}%',
|
||||
stable: '稳定',
|
||||
volatile: '波动',
|
||||
viaCl: '经 CL {{fee}}% ts{{ts}}',
|
||||
bpsVsKyber: '对比 kyber {{bps}} bps',
|
||||
noNativePool: '该交易对没有直接的 UP33 池',
|
||||
needsWeth: '需要 WETH — 先包装',
|
||||
slippage: '滑点',
|
||||
refresh: '↻ 刷新',
|
||||
execVia: '通过 {{route}} 执行 →',
|
||||
noRoute: '无路由',
|
||||
minReceived: '最少到账: {{amt}} {{sym}} @ {{slip}}%',
|
||||
nativeWethNote: '原生路由到账的是 WETH(不是 ETH)',
|
||||
chooseSlippage: '请选择滑点',
|
||||
wrapBtn: '包装 {{amt}} ETH',
|
||||
unwrapBtn: '解包 {{amt}} WETH',
|
||||
wrapNote: '1:1,无费用 — WETH9 {{addr}}',
|
||||
stWrap: '包装 {{amt}} ETH → WETH',
|
||||
stUnwrap: '解包 {{amt}} WETH → ETH',
|
||||
stSwapKyber: '兑换 {{amt}} {{a}} → {{b}} [KYBER]',
|
||||
stSwapV2: '兑换 {{amt}} {{a}} → {{b}} [UP33 v2]',
|
||||
stSwapCl: '兑换 {{amt}} {{a}} → {{b}} [UP33 CL ts{{ts}}]',
|
||||
errNoQuote: 'kyber 报价不可用',
|
||||
errNeedsWeth: 'UP33 原生路由需要 WETH — 先把 ETH 包装成 WETH(选择 ETH→WETH)',
|
||||
stSwapDirect: '兑换 {{amt}} {{a}} → {{b}} [{{route}}]',
|
||||
errQuoteMoved: '最新报价低于页面显示的最小到账 — 请核对后重试',
|
||||
receivedBelowMinimum: '确认到账 {{got}} 低于最小值 {{min}} — 请先排查再重试',
|
||||
},
|
||||
limit: {
|
||||
intro: '> 做挂单方,不做吃单方:在市场价之外挂单边 LP — 不付交易费,成交过程还赚手续费',
|
||||
@@ -470,4 +534,54 @@ export const zh: typeof en = {
|
||||
orderLive: '区间订单 #{{id}} 已挂出 — {{sym}} 升值时逐步成交',
|
||||
track: '在仓位页跟踪',
|
||||
},
|
||||
bridge: {
|
||||
chainTip: '选择链 — Robinhood Chain (id {{id}}) 永远是路径的一端',
|
||||
homeTag: '主链',
|
||||
balFailed: '余额读取失败 — 点击重试({{err}})',
|
||||
from: '从',
|
||||
to: '到',
|
||||
flipTip: '反转方向',
|
||||
route: '路由',
|
||||
etaS: '约{{s}}秒',
|
||||
etaMin: '约{{m}}分钟',
|
||||
minShort: '最少 {{amt}} {{sym}}',
|
||||
quoteFailed: '报价失败',
|
||||
kEta: '预计时间',
|
||||
kProviderCost: '桥成本',
|
||||
impactTip: '到账 vs 发送的全部损耗 — 桥成本 + 终端费。点击展开',
|
||||
terminalFeeTip: '终端费 {{pct}}% — 在报价内收取,不产生额外转账',
|
||||
impactWarn: '总损耗 {{pct}}% 偏高 — 当前路由流动性薄。可对比各家报价、减小金额,或改用 USDG 出入金',
|
||||
noRoute: '无可用路由',
|
||||
execVia: '通过 {{provider}} 跨链 →',
|
||||
stDeposit: '跨链 {{amt}} {{sym}}: {{from}} → {{to}}',
|
||||
stgApprove: '授权',
|
||||
stgDeposit: '发送',
|
||||
stgSent: '已发送',
|
||||
sentNote: '入金已确认 — 到账进度见上方 PENDING',
|
||||
filled: '跨链完成 — 资产已到目标链',
|
||||
refunded: '未成交 — 已在源链退款(扣除 gas)',
|
||||
fillFailed: '目标链成交失败 — 请用入金交易哈希到桥方浏览器核查',
|
||||
quoteExpired: '报价已过期 — 刷新报价后重试',
|
||||
switchFailed: '切换网络被拒绝: {{err}}',
|
||||
tokenTip: '选择代币 — 仅列出各桥实际支持的同代币路由',
|
||||
nRoutes: '{{n}} 条路由',
|
||||
nRoute1: '1 条路由',
|
||||
discovering: '正在发现路由…',
|
||||
noTokens: '该链对当前无可桥代币',
|
||||
discoverFailed: '路由发现失败',
|
||||
retry: '↻ 重试',
|
||||
canonical: 'CANONICAL',
|
||||
canonicalTip: '官方 Arbitrum 桥 — 1:1 无损,仅付 L1 gas;实测到账约 10 分钟。价格最优即排第一,不论速度',
|
||||
pendingTitle: 'PENDING',
|
||||
pendingTracked: '入金已发送 — 预计 {{eta}} 到账,进度见 BRIDGE › PENDING',
|
||||
etaLeft: '剩 {{eta}}',
|
||||
verifying: '核对中',
|
||||
pFilled: '已到账',
|
||||
pRefunded: '已退款',
|
||||
pFailed: '失败',
|
||||
pStale: '仍在等待',
|
||||
stale: '等待超过 1 小时 — 可手动重查,或用入金交易哈希到浏览器核实',
|
||||
recheckTip: '立即查询状态',
|
||||
dismissTip: '从列表移除(不影响转账本身)',
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Canonical Arbitrum bridge ("portal") provider: native ETH deposits
|
||||
// Ethereum → Robinhood via Inbox.depositEth(). Lossless 1:1 — no solver, no
|
||||
// bridge fee, only L1 gas — but slow (real deposits measured 484–689s on
|
||||
// 2026-07-18, so it quotes PORTAL_ETA_SEC). Scope is deliberately narrow:
|
||||
// - ETH only. ERC-20s canonically bridge into the gateway's OWN wrapped
|
||||
// tokens: verified on-chain that calculateL2TokenAddress(mainnet USDG)
|
||||
// != the real Robinhood USDG (which is LayerZero-OFT-issued instead).
|
||||
// - deposits only. Canonical withdrawals sit out the rollup challenge
|
||||
// period (days) — the external portal link stays the escape hatch.
|
||||
// Fill tracking needs no status API at all: an EthDeposit's child tx hash is
|
||||
// derivable from the deposit receipt, so "did it arrive" is one receipt read
|
||||
// on our own Robinhood RPC. Formula validated against 3 real deposit pairs.
|
||||
import {
|
||||
concatHex,
|
||||
decodeAbiParameters,
|
||||
getAddress,
|
||||
keccak256,
|
||||
pad,
|
||||
toHex,
|
||||
toRlp,
|
||||
type Address,
|
||||
type Hex,
|
||||
type TransactionReceipt,
|
||||
} from 'viem'
|
||||
import {
|
||||
NATIVE_SENTINEL,
|
||||
PORTAL_ETA_SEC,
|
||||
PORTAL_INBOX,
|
||||
PORTAL_PARENT_CHAIN_ID,
|
||||
type ResolvedIntent,
|
||||
} from '../../config/bridge'
|
||||
import { CHAIN_ID } from '../../config/addresses'
|
||||
import { BridgeQuoteError, type BridgeQuote } from './types'
|
||||
|
||||
/** Inbox.depositEth() — credits msg.sender (its alias for contract wallets) on the child chain */
|
||||
export const DEPOSIT_ETH_CALLDATA = '0x439370b1' as Hex
|
||||
|
||||
/** topic0 of Inbox's InboxMessageDelivered(uint256 indexed messageNum, bytes data) */
|
||||
const INBOX_MSG_TOPIC = '0xff64905f73a67fb594e0f940a8075a860db489ad991e032f48c81123eb52d60b'
|
||||
|
||||
export function quotePortal(leg: ResolvedIntent, amount: bigint): BridgeQuote {
|
||||
if (
|
||||
leg.originChainId !== PORTAL_PARENT_CHAIN_ID ||
|
||||
leg.destChainId !== CHAIN_ID ||
|
||||
leg.inputToken.toLowerCase() !== NATIVE_SENTINEL.toLowerCase()
|
||||
) {
|
||||
throw new BridgeQuoteError('canonical bridge only deposits native ETH from Ethereum')
|
||||
}
|
||||
return {
|
||||
provider: 'portal',
|
||||
outputAmount: amount,
|
||||
minOutput: amount,
|
||||
etaSec: PORTAL_ETA_SEC,
|
||||
steps: [
|
||||
{
|
||||
kind: 'deposit',
|
||||
chainId: PORTAL_PARENT_CHAIN_ID,
|
||||
to: PORTAL_INBOX,
|
||||
data: DEPOSIT_ETH_CALLDATA,
|
||||
value: amount,
|
||||
},
|
||||
],
|
||||
tracker: { provider: 'portal' },
|
||||
expiresAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** L1→L2 sender aliasing (AddressAliasHelper) — the Inbox applies it to every
|
||||
* delayed message's sender, and the child EthDeposit tx carries the alias */
|
||||
export function aliasL1Address(addr: Address): Address {
|
||||
const OFFSET = 0x1111000000000000000000000000000000001111n
|
||||
return getAddress(toHex((BigInt(addr) + OFFSET) & ((1n << 160n) - 1n), { size: 20 }))
|
||||
}
|
||||
|
||||
const rlpNum = (x: bigint): Hex => (x === 0n ? '0x' : toHex(x))
|
||||
|
||||
/** child-chain tx hash of an EthDeposit message: keccak256(0x64 ‖ rlp([chainId,
|
||||
* msgNum₃₂, aliasedSender, dest, value])) — 0x64 = ArbitrumDepositTx type.
|
||||
* Validated 3/3 against real Ethereum→Robinhood deposits (2026-07-18). */
|
||||
export function childEthDepositTxHash(
|
||||
chainId: bigint,
|
||||
messageNum: bigint,
|
||||
aliasedSender: Address,
|
||||
dest: Address,
|
||||
value: bigint,
|
||||
): Hex {
|
||||
return keccak256(
|
||||
concatHex(['0x64', toRlp([rlpNum(chainId), pad(toHex(messageNum), { size: 32 }), aliasedSender, dest, rlpNum(value)])]),
|
||||
)
|
||||
}
|
||||
|
||||
/** derive the child tx hash from a confirmed depositEth receipt: messageNum +
|
||||
* packed (dest ‖ value) come from the Inbox's InboxMessageDelivered log, the
|
||||
* sender is the alias of the tx sender (the Inbox aliases unconditionally) */
|
||||
export function parseEthDepositReceipt(receipt: TransactionReceipt): Hex | null {
|
||||
const log = receipt.logs.find(
|
||||
(l) => l.address.toLowerCase() === PORTAL_INBOX.toLowerCase() && l.topics[0] === INBOX_MSG_TOPIC,
|
||||
)
|
||||
if (!log || !log.topics[1] || !receipt.from) return null
|
||||
const [packed] = decodeAbiParameters([{ type: 'bytes' }], log.data)
|
||||
if (packed.length < 2 + 40) return null
|
||||
const dest = getAddress(`0x${packed.slice(2, 42)}`)
|
||||
const value = BigInt(`0x${packed.slice(42) || '0'}`)
|
||||
return childEthDepositTxHash(BigInt(CHAIN_ID), BigInt(log.topics[1]), aliasL1Address(receipt.from), dest, value)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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, '')
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { fmtCompact, fmtNum, fmtUsd, sanitizeAmountInput } from './format'
|
||||
|
||||
test('amount input: fraction clamps to the token decimals, never silently zero', () => {
|
||||
// 6-decimal USDG: the 7th fraction digit is untypeable, not a hidden zero
|
||||
assert.equal(sanitizeAmountInput('0.0000001', 6), '0.000000')
|
||||
assert.equal(sanitizeAmountInput('0.0000015', 6), '0.000001')
|
||||
assert.equal(sanitizeAmountInput('0.00005', 18), '0.00005') // within precision → untouched
|
||||
assert.equal(sanitizeAmountInput('1.5', 0), '1')
|
||||
})
|
||||
|
||||
test('amount input: scientific-notation pastes expand exactly (string math)', () => {
|
||||
assert.equal(sanitizeAmountInput('5e-5', 18), '0.00005')
|
||||
assert.equal(sanitizeAmountInput('3E-7', 18), '0.0000003')
|
||||
assert.equal(sanitizeAmountInput('1.2e3', 18), '1200')
|
||||
assert.equal(sanitizeAmountInput('1.2E+3', 6), '1200')
|
||||
assert.equal(sanitizeAmountInput('2.5e-7', 6), '0.000000') // expanded, then clamped
|
||||
assert.equal(sanitizeAmountInput('1e31', 18), null) // absurd exponents rejected
|
||||
assert.equal(sanitizeAmountInput('e5', 18), null) // no mantissa = not an amount
|
||||
})
|
||||
|
||||
test('amount input: normalization and rejection', () => {
|
||||
assert.equal(sanitizeAmountInput('', 18), '')
|
||||
assert.equal(sanitizeAmountInput('0,05', 18), '0.05') // decimal comma
|
||||
assert.equal(sanitizeAmountInput(' 0.05 ', 18), '0.05')
|
||||
assert.equal(sanitizeAmountInput('.05', 18), '.05') // leading dot parses fine downstream
|
||||
assert.equal(sanitizeAmountInput('abc', 18), null)
|
||||
assert.equal(sanitizeAmountInput('1.2.3', 18), null)
|
||||
assert.equal(sanitizeAmountInput('-5', 18), null)
|
||||
})
|
||||
|
||||
test('amount input: Chinese-IME fullwidth characters normalize instead of vanishing', () => {
|
||||
// rejecting just the fullwidth dot turned "0。05" into "005" — a 100× error
|
||||
assert.equal(sanitizeAmountInput('0。05', 6), '0.05')
|
||||
assert.equal(sanitizeAmountInput('0.05', 6), '0.05')
|
||||
assert.equal(sanitizeAmountInput('0.05', 18), '0.05')
|
||||
assert.equal(sanitizeAmountInput('0,05', 18), '0.05') // fullwidth comma = decimal comma
|
||||
assert.equal(sanitizeAmountInput('0。00000001', 6), '0.000000') // normalize, then clamp
|
||||
})
|
||||
|
||||
test('fmtNum compresses many-zero decimals to subscript notation', () => {
|
||||
assert.equal(fmtNum(0.000072051), '0.0₄72051')
|
||||
assert.equal(fmtNum(0.00003), '0.0₄3')
|
||||
})
|
||||
|
||||
test('compact table figures stay short enough for a phone column', () => {
|
||||
assert.equal(fmtCompact(4_049), '4K')
|
||||
assert.equal(fmtCompact(2_844_349), '2.8M')
|
||||
// Intl's compact notation stops at T and then degrades into a grouped number:
|
||||
// 2.99e23 used to render "298,973,758,851.7T" — 18 characters, nowrap, which
|
||||
// widened the pool table past the viewport and forced sideways scrolling
|
||||
assert.equal(fmtCompact(2.99e23), '3.0e+23')
|
||||
for (const x of [0, 1, 999, 1e6, 1e12, 1e15, 2.99e23, 2.95e49, -1e30, 1e-9])
|
||||
assert.ok(fmtCompact(x).length <= 8, `fmtCompact(${x}) = "${fmtCompact(x)}" is too wide`)
|
||||
})
|
||||
|
||||
test('fmtUsd is exact for real money and bounded for impossible money', () => {
|
||||
assert.equal(fmtUsd(4_049), '$4,049')
|
||||
assert.equal(fmtUsd(12.345), '$12.35')
|
||||
assert.equal(fmtUsd(0.004), '<$0.01')
|
||||
// third-party figures (dexscreener) are not sanity-checked upstream: past a
|
||||
// trillion the number is wrong, and rendering it exactly costs 30 columns
|
||||
assert.equal(fmtUsd(2.99e23), '$3.0e+23')
|
||||
assert.ok(fmtUsd(298_973_758_851_651_660_000_000).length <= 9)
|
||||
})
|
||||
+61
-14
@@ -1,6 +1,14 @@
|
||||
import { formatUnits } from 'viem'
|
||||
|
||||
/** significant-digit number formatting with thousands separators */
|
||||
const SUB_DIGITS = '₀₁₂₃₄₅₆₇₈₉'
|
||||
const subscriptNum = (n: number): string =>
|
||||
String(n)
|
||||
.split('')
|
||||
.map((d) => SUB_DIGITS[Number(d)])
|
||||
.join('')
|
||||
|
||||
/** significant-digit number formatting with thousands separators.
|
||||
many-zero decimals compress to the common web3 notation: 0.000072051 → 0.0₄72051 */
|
||||
export function fmtNum(x: number, sig = 5): string {
|
||||
if (!Number.isFinite(x)) return '—'
|
||||
if (x === 0) return '0'
|
||||
@@ -19,6 +27,8 @@ export function fmtNum(x: number, sig = 5): string {
|
||||
s = a.toFixed(Math.min(exp + sig, 18))
|
||||
}
|
||||
s = s.replace(/\.?0+$/, '')
|
||||
const zeros = s.match(/^0\.(0{4,})(?=[1-9])/)
|
||||
if (zeros) s = `0.0${subscriptNum(zeros[1].length)}${s.slice(2 + zeros[1].length)}`
|
||||
}
|
||||
return (neg ? '-' : '') + s
|
||||
}
|
||||
@@ -27,10 +37,55 @@ export function fmtAmount(v: bigint, decimals: number, sig = 5): string {
|
||||
return fmtNum(Number(formatUnits(v, decimals)), sig)
|
||||
}
|
||||
|
||||
/** compact amount for dense table cells: 24.9M, 338.4K, 12.4, 0.0421 */
|
||||
/** exact decimal-point shift for scientific-notation pastes — string math only,
|
||||
* a float round-trip would corrupt exactly the tiny amounts this serves */
|
||||
function shiftDecimal(int: string, frac: string, exp: number): string | null {
|
||||
if (!Number.isFinite(exp) || Math.abs(exp) > 30) return null
|
||||
const digits = int + frac
|
||||
const point = int.length + exp
|
||||
if (point <= 0) return `0.${'0'.repeat(-point)}${digits}`
|
||||
if (point >= digits.length) return digits + '0'.repeat(point - digits.length)
|
||||
return `${digits.slice(0, point)}.${digits.slice(point)}`
|
||||
}
|
||||
|
||||
/** normalize user-typed token amounts (the web3 small-amount conventions):
|
||||
* whitespace stripped, decimal comma → dot, pasted scientific notation
|
||||
* ("5e-5") expanded exactly, and the fraction CLAMPED to the token's decimals
|
||||
* — text the token cannot represent must not sit in the box silently quoting
|
||||
* zero. Returns the normalized string, or null when the text is not an amount
|
||||
* (caller keeps the previous value, the controlled input swallows the key). */
|
||||
export function sanitizeAmountInput(raw: string, decimals: number): string | null {
|
||||
let s = raw
|
||||
.replace(/\s+/g, '')
|
||||
// Chinese-IME width normalization: with an IME active the digit/dot keys
|
||||
// emit fullwidth 0-9/。/./,— rejecting just the dot turned a typed
|
||||
// "0。05" into "005", a 100× amount error, so map instead of reject
|
||||
.replace(/[0-9]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xfee0))
|
||||
.replace(/[。.]/g, '.')
|
||||
.replace(',', ',')
|
||||
.replace(',', '.')
|
||||
if (s === '') return ''
|
||||
const sci = s.match(/^(\d*)\.?(\d*)[eE]([+-]?\d+)$/)
|
||||
if (sci && (sci[1] || sci[2])) {
|
||||
const shifted = shiftDecimal(sci[1], sci[2], Number(sci[3]))
|
||||
if (shifted === null) return null
|
||||
s = shifted
|
||||
}
|
||||
if (!/^\d*\.?\d*$/.test(s)) return null
|
||||
const [int, frac = ''] = s.split('.')
|
||||
if (frac.length > decimals) return decimals === 0 ? int : `${int}.${frac.slice(0, decimals)}`
|
||||
return s
|
||||
}
|
||||
|
||||
/** compact amount for dense table cells: 24.9M, 338.4K, 12.4, 0.0421.
|
||||
* Bounded by construction — ~8 characters whatever the input. Intl's compact
|
||||
* notation stops at T and then silently degrades into a grouped number
|
||||
* ("298,973,758,851.7T", 18 chars), which is the one thing a fixed-width cell
|
||||
* cannot absorb: it widened the pool table past the viewport. */
|
||||
export function fmtCompact(x: number): string {
|
||||
if (!Number.isFinite(x)) return '—'
|
||||
if (x !== 0 && Math.abs(x) < 1) return fmtNum(x, 3)
|
||||
if (Math.abs(x) >= 1e15) return x.toExponential(1)
|
||||
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(x)
|
||||
}
|
||||
|
||||
@@ -44,6 +99,10 @@ export function fmtUsd(x: number | string | undefined): string {
|
||||
// bounded width: sub-cent USD precision is noise everywhere in this app,
|
||||
// and long fractions (dust TVLs) were stretching table columns
|
||||
if (n > 0 && n < 0.01) return '<$0.01'
|
||||
// exact dollars are this function's job, but the inputs include third-party
|
||||
// figures (dexscreener) we don't get to sanity-check: past a trillion the
|
||||
// number is wrong, and rendering it exactly costs 30 characters of column
|
||||
if (Math.abs(n) >= 1e12) return '$' + fmtCompact(n)
|
||||
return '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 1000 ? 0 : 2 })
|
||||
}
|
||||
|
||||
@@ -56,18 +115,6 @@ export function shortAddr(a: string): string {
|
||||
return a.slice(0, 6) + '…' + a.slice(-4)
|
||||
}
|
||||
|
||||
export function fmtDur(seconds: number): string {
|
||||
if (seconds <= 0) return '0s'
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`
|
||||
if (m > 0) return `${m}m ${s}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
/** signed bps difference of a vs b (positive = a better) */
|
||||
export function bpsDiff(a: bigint, b: bigint): number {
|
||||
if (b === 0n) return 0
|
||||
|
||||
+16
-123
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 次后暂停/)
|
||||
})
|
||||
@@ -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(' + ')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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
@@ -1,9 +1,9 @@
|
||||
// ZAP: single-token add-liquidity. The user funds a position with ONE token
|
||||
// (or native ETH); we solve how much of it to swap into the counter-token so
|
||||
// the two piles match the deposit ratio the target needs, swap via the gated
|
||||
// Kyber path, then deposit — one flow, N wallet-signed txs, halting on any
|
||||
// failure (a halt never strands value: every intermediate asset is a normal
|
||||
// wallet balance).
|
||||
// the two piles match the deposit ratio the target needs, swap through the
|
||||
// best direct Uniswap/UP33 route, then deposit — one flow, N wallet-signed
|
||||
// txs, halting on any failure (a halt never strands value: every intermediate
|
||||
// asset is a normal wallet balance).
|
||||
//
|
||||
// Split solve: the needed ratio ρ (raw counter-units per raw kept-unit) comes
|
||||
// from CL band math (or v2 reserves); with quote rate q̂ (counter per swapped
|
||||
@@ -13,11 +13,11 @@
|
||||
// mismatch (price impact shifting the band ratio) surfaces as DUST: a small
|
||||
// leftover the deposit doesn't pull, which stays in the wallet. Planning uses
|
||||
// floats (ratios only); every on-chain amount stays bigint.
|
||||
import type { Address } from 'viem'
|
||||
import { readContract, sendTransaction, writeContract } from 'wagmi/actions'
|
||||
import type { Address, PublicClient, TransactionReceipt } from 'viem'
|
||||
import { readContract, writeContract } from 'wagmi/actions'
|
||||
import { clPmAbi, uniV2PairAbi, uniV2RouterAbi, uniV3PmAbi, v2RouterAbi, wethAbi } from '../abi'
|
||||
import { ADDR, CHAIN_ID, UNI } from '../config/addresses'
|
||||
import { ENV } from '../config/env'
|
||||
import { ADDR, CHAIN_ID, NATIVE, UNI } from '../config/addresses'
|
||||
import { zapFee } from '../config/env'
|
||||
import { wagmiConfig } from '../config/wagmi'
|
||||
import { t } from '../i18n'
|
||||
import {
|
||||
@@ -28,52 +28,74 @@ import {
|
||||
minAmountsForLiquidity,
|
||||
Q96,
|
||||
} from './clmath'
|
||||
import {
|
||||
directRouteLabel,
|
||||
isNative,
|
||||
quoteDirectCandidates,
|
||||
type DirectCandidate,
|
||||
type DirectQuotes,
|
||||
type DirectRoute,
|
||||
} from './directSwap'
|
||||
import { fmtAmount } from './format'
|
||||
import { kyberRoute, NATIVE, type KyberRouteData } from './kyber'
|
||||
import { buildGatedKyberTx } from './kyberExec'
|
||||
import { deadline, ensureAllowance, fetchSqrtPriceX96, receivedOf, step } from './tx'
|
||||
import { fetchSolverQuote, solverRouteLabel, solverVenueFeeBps } from './solver'
|
||||
import { stakeClNft, stakeV2Lp, mintedTokenId } from './stake'
|
||||
import { executeSolverSwap, executeSwap, SlippageError } from './swapExec'
|
||||
import { solveSwap, splitOutside } from './zapMath'
|
||||
import { deadline, ensureAllowance, fetchSqrtPriceX96, receivedOf, step, shortErr, type StepFailWhy } from './tx'
|
||||
import { txlog } from './txlog'
|
||||
import type { ClPool, Pool, TokenInfo, V2Pool } from '../types'
|
||||
|
||||
const MINS_BPS = 100 // deposit mins: 1% band-edge, same as the PAIR flows
|
||||
/** deposit mins: 1% band-edge, same as the PAIR flows (panel copy quotes it) */
|
||||
export const DEPOSIT_MINS_BPS = 100
|
||||
|
||||
export type ZapTarget =
|
||||
| { kind: 'cl-mint'; pool: ClPool; tickLower: number; tickUpper: number }
|
||||
| { kind: 'cl-increase'; pool: ClPool; tickLower: number; tickUpper: number; tokenId: bigint; npm: Address }
|
||||
| { kind: 'v2'; pool: V2Pool }
|
||||
|
||||
/** the swap leg's venue: SHEEP CHOICE (solver-routed, fee charged server-side
|
||||
* inside the Settler tx) or a direct route (fee via the router's *WithFee) */
|
||||
export type ZapSwapVia =
|
||||
| { via: 'direct'; route: DirectRoute }
|
||||
| { via: 'solver'; routeLabel: string; venueFeeBps: number }
|
||||
|
||||
/** one planned swap: a slice of the input bought into a pool side */
|
||||
export type ZapLeg = {
|
||||
/** true → buys pool.token0; false → pool.token1 */
|
||||
buyIs0: boolean
|
||||
swapIn: bigint
|
||||
via: ZapSwapVia
|
||||
estOut: bigint
|
||||
impactBps: number | null
|
||||
}
|
||||
|
||||
export type ZapPlan = {
|
||||
/** what the user selected (NATIVE sentinel allowed) */
|
||||
tokenIn: Address
|
||||
nativeIn: boolean
|
||||
/** erc20 actually spent (WETH when nativeIn) */
|
||||
erc20In: Address
|
||||
inIs0: boolean
|
||||
/** which pool side the input IS — null when it's an outside token
|
||||
* (WETH/USDG/anything), in which case BOTH sides come from swap legs */
|
||||
inIs0: boolean | null
|
||||
amountIn: bigint
|
||||
swapIn: bigint
|
||||
/** input deposited as-is, no swap (pool-side input only; 0 otherwise) */
|
||||
keep: bigint
|
||||
route: KyberRouteData | null // final planning quote (null = no swap needed)
|
||||
estOut: bigint
|
||||
/** swap legs acquiring whatever the input doesn't cover (0–2) */
|
||||
legs: ZapLeg[]
|
||||
dep0: bigint
|
||||
dep1: bigint
|
||||
/** CL only: liquidity the planned deposit mints at the planning price */
|
||||
liquidity: bigint
|
||||
dust0: bigint
|
||||
dust1: bigint
|
||||
impactBps: number | null // swap value lost to impact+lp fees (platform fee excluded)
|
||||
routeLabel: string
|
||||
/** worst leg's size impact versus a small executable quote; terminal fee excluded */
|
||||
impactBps: number | null
|
||||
}
|
||||
|
||||
function poolOf(tgt: ZapTarget): Pool {
|
||||
return tgt.pool
|
||||
}
|
||||
|
||||
const low = (a: string) => a.toLowerCase()
|
||||
const isNat = (a: Address) => low(a) === low(NATIVE)
|
||||
|
||||
/** needed raw-unit ratio (counter per kept) + spot rate seed, by target */
|
||||
function needAndSpot(tgt: ZapTarget, inIs0: boolean): { rho: number; spot: number } {
|
||||
const pool = poolOf(tgt)
|
||||
function needAndSpot(target: ZapTarget, inIs0: boolean): { rho: number; spot: number } {
|
||||
const pool = target.pool
|
||||
if (pool.kind === 'v2') {
|
||||
const r0 = Number(pool.reserve0)
|
||||
const r1 = Number(pool.reserve1)
|
||||
@@ -81,7 +103,7 @@ function needAndSpot(tgt: ZapTarget, inIs0: boolean): { rho: number; spot: numbe
|
||||
const rho = inIs0 ? r1 / r0 : r0 / r1
|
||||
return { rho, spot: rho } // marginal v2 price == reserve ratio
|
||||
}
|
||||
const { tickLower, tickUpper } = tgt as Extract<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: 1–2 kyber quotes.
|
||||
* (no route, empty pool, dust amounts). Network: 1–2 direct-route quote rounds.
|
||||
*/
|
||||
/** display label for a planned swap leg */
|
||||
export function zapRouteLabel(swap: ZapSwapVia): string {
|
||||
return swap.via === 'direct' ? directRouteLabel(swap.route) : swap.routeLabel
|
||||
}
|
||||
|
||||
/** worst swap-leg venue fee in bps — autoSlippage's volatility prior (0 = no swap) */
|
||||
export function zapVenueFeeBps(plan: ZapPlan): number {
|
||||
let worst = 0
|
||||
for (const leg of plan.legs) {
|
||||
const fee = leg.via.via === 'direct' ? leg.via.route.feePpm / 100 : leg.via.venueFeeBps
|
||||
if (fee > worst) worst = fee
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
/** one planning-time quote for the swap leg, whichever venue answered best */
|
||||
type SwapLegQuote = { via: ZapSwapVia; amountOut: bigint; impactBps: number | null }
|
||||
|
||||
/**
|
||||
* Quote the swap leg across BOTH kinds: the solver (SHEEP CHOICE — zap's fee
|
||||
* charged server-side via the request's feeBps override) and the direct
|
||||
* venues (fee inside the client-side quote). Both amounts are net of the
|
||||
* terminal fee, so the comparison is apples-to-apples; the solver wins ties
|
||||
* like the SWAP tab. One side failing alone is fine — a total blank rethrows
|
||||
* the direct path's (localized) error.
|
||||
*/
|
||||
async function quoteSwapLeg(
|
||||
client: PublicClient,
|
||||
pools: readonly Pool[] | null,
|
||||
tokenIn: Address,
|
||||
tokenOut: Address,
|
||||
amountIn: bigint,
|
||||
feeBps: number,
|
||||
): Promise<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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { solveSwap, splitOutside } from './zapMath'
|
||||
|
||||
const A = 10n ** 18n
|
||||
|
||||
test('solveSwap: single-sided ratios take none or all', () => {
|
||||
assert.equal(solveSwap(A, 0, 2), 0n)
|
||||
assert.equal(solveSwap(A, Infinity, 2), A)
|
||||
})
|
||||
|
||||
test('solveSwap: swapped slice matches the ratio at the quoted rate', () => {
|
||||
// rho = 1 counter per kept, rate = 1 → half swaps
|
||||
assert.equal(solveSwap(A, 1, 1), A / 2n)
|
||||
// dep_counter / dep_kept = (s·rate)/(A−s) must equal rho
|
||||
const rho = 3.7
|
||||
const rate = 0.82
|
||||
const s = solveSwap(A, rho, rate)
|
||||
const ratio = (Number(s) * rate) / Number(A - s)
|
||||
assert.ok(Math.abs(ratio - rho) / rho < 1e-9)
|
||||
})
|
||||
|
||||
test('splitOutside: single-sided targets buy one side with everything', () => {
|
||||
assert.deepEqual(splitOutside(A, 0, 1), [{ buyIs0: true, swapIn: A }])
|
||||
assert.deepEqual(splitOutside(A, Infinity, 1), [{ buyIs0: false, swapIn: A }])
|
||||
})
|
||||
|
||||
test('splitOutside: slices sum to the input and hit the target ratio', () => {
|
||||
const rho = 2.5 // want dep1/dep0 = 2.5
|
||||
const r0 = 30_000 // token0 per input
|
||||
const r1 = 12_000 // token1 per input
|
||||
const spec = splitOutside(A, rho, r0 / r1)
|
||||
assert.equal(spec.length, 2)
|
||||
const s0 = spec.find((s) => s.buyIs0)!.swapIn
|
||||
const s1 = spec.find((s) => !s.buyIs0)!.swapIn
|
||||
assert.equal(s0 + s1, A)
|
||||
const dep0 = Number(s0) * r0
|
||||
const dep1 = Number(s1) * r1
|
||||
assert.ok(Math.abs(dep1 / dep0 - rho) / rho < 1e-9)
|
||||
})
|
||||
|
||||
test('splitOutside: equal rates and rho=1 split evenly', () => {
|
||||
const spec = splitOutside(A, 1, 1)
|
||||
assert.equal(spec.length, 2)
|
||||
assert.equal(spec[0].swapIn, A / 2n)
|
||||
assert.equal(spec[1].swapIn, A / 2n)
|
||||
})
|
||||
|
||||
test('splitOutside: sub-ppm slivers fold into the other leg', () => {
|
||||
// rho so extreme the t0 slice is < A/1e6 → one leg only
|
||||
const spec = splitOutside(A, 10_000_000, 1)
|
||||
assert.equal(spec.length, 1)
|
||||
assert.equal(spec[0].buyIs0, false)
|
||||
assert.equal(spec[0].swapIn, A)
|
||||
})
|
||||
|
||||
test('splitOutside: degenerate rate ratio degrades to one leg, never throws', () => {
|
||||
for (const bad of [0, NaN, Infinity]) {
|
||||
const spec = splitOutside(A, 1, bad)
|
||||
assert.equal(spec.length, 1)
|
||||
assert.equal(spec[0].swapIn, A)
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user