// app-screens.jsx — AnarchyFi reskinned UI screens
// Exports to window: TokenIcon, TokenPair, Nav, SwapScreen, LiquidityScreen, ManageScreen, POOLS
const { useState, useRef, useEffect } = React;
function useMobile() {
const [m, setM] = useState(typeof window !== 'undefined' && window.innerWidth < 768);
useEffect(() => {
const fn = () => setM(window.innerWidth < 768);
window.addEventListener('resize', fn);
return () => window.removeEventListener('resize', fn);
}, []);
return m;
}
/* Epoch clock — 7d epochs aligned to MinterUpgradeable.active_period() */
function useCountdown(target) {
const [now, setNow] = useState(Date.now());
useEffect(() => { const iv = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(iv); }, []);
const s = target == null ? 0 : Math.max(0, Math.floor((target - now) / 1000));
return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60, total: s, ready: target != null };
}
const { fmt, ethers: E } = window.Chain;
const DEADLINE = () => BigInt(Math.floor(Date.now() / 1000) + 20 * 60);
const SLIPPAGE_BPS = 50n;
const withSlip = (v) => (v * (10000n - SLIPPAGE_BPS)) / 10000n;
const SECONDS_PER_YEAR = 365 * 86400;
/* PairAPI row → the shape the tables render. APR = weekly gauge emissions in USD annualised over gauge TVL. */
function toPool(p, prices, archPrice) {
const staked = p.gauge !== E.ZeroAddress && p.total_supply > 0n
? fmt.toNum(p.gauge_total_supply, 18) * p.lpPrice : 0;
const weeklyUsd = fmt.toNum(p.emissions, 18) * archPrice;
const apr = staked > 0 ? (weeklyUsd * 52 / staked) * 100 : 0;
const myPool = fmt.toNum(p.account_lp_balance, 18) * p.lpPrice;
const myStake = fmt.toNum(p.account_gauge_balance, 18) * p.lpPrice;
return {
id: p.pair_address, address: p.pair_address, pair: [p.token0_symbol, p.token1_symbol],
type: p.stable ? 'Stable' : 'Volatile', stable: p.stable,
tvl: fmt.usd(p.tvl), tvlNum: p.tvl, apr: p.gauge === E.ZeroAddress ? '—' : fmt.pct(apr), aprNum: apr,
boosted: false, votes: p.gauge !== E.ZeroAddress, gauge: p.gauge, bribe: p.bribe,
myPool: myPool > 0 ? fmt.usd(myPool) : '—', myStake: myStake > 0 ? fmt.usd(myStake) : '—',
raw: p,
};
}
const TOKEN_CFG = {
ARCH: { bg: '#d62534', label: 'A' },
ETH: { bg: '#627EEA', label: 'Ξ' },
BTC: { bg: '#F7931A', label: '₿' },
USDC: { bg: '#2775CA', label: '$' },
USDT: { bg: '#26A17B', label: '₮' },
ARB: { bg: '#28A0F0', label: 'A' },
BNB: { bg: '#F0B90B', label: 'B' },
WBNB: { bg: '#F0B90B', label: 'B' },
WETH: { bg: '#627EEA', label: 'Ξ' },
FTM: { bg: '#1969FF', label: 'F' },
};
function TokenIcon({ symbol, size = 24, style }) {
const c = TOKEN_CFG[symbol] || { bg: '#4a4640', label: (symbol || '?').slice(0, 1) };
return (
{c.label}
);
}
function TokenPair({ tokens, size = 24 }) {
return (
);
}
/* ── NAV ─────────────────────────────────────────────────────────────── */
const NAV_TABS = [['swap','SWAP'],['liquidity','LIQUIDITY'],['vote','VOTE'],['delegate','DELEGATE'],['pro','PRO'],['match','MATCH'],['bribe','BRIBE'],['il','IL']];
const NAV_TABS_SHORT = { liquidity:'LIQ', delegate:'DELG' };
function Nav({ screen, setScreen, accent, connected, onWalletClick }) {
const isMobile = useMobile();
const chain = useChain();
const epoch = useEpoch();
const ep = useCountdown(epoch.endsAt);
const short = chain.account ? `${chain.account.slice(0, 6)}...${chain.account.slice(-4)}` : '';
return (
);
}
/* ── CARD WRAPPER ────────────────────────────────────────────────────── */
function Card({ children, style, accent, width }) {
return (
{/* corner brackets */}
{[['top','left'],['top','right'],['bottom','left'],['bottom','right']].map(([v,h]) => (
))}
{children}
);
}
/* ── SWAP SCREEN ─────────────────────────────────────────────────────── */
function SwapScreen({ accent, onTx }) {
const isMobile = useMobile();
const chain = useChain();
const TOKENS_LIST = chain.tokens.map(t => t.symbol);
const [fromTok, setFromTok] = useState('USDC');
const [toTok, setToTok] = useState(null);
const [fromAmt, setFromAmt] = useState('');
const [picking, setPicking] = useState(null);
const [quote, setQuote] = useState(null); // { out, outNoFee, stable, error }
useEffect(() => { if (TOKENS_LIST.length && !TOKENS_LIST.includes(fromTok)) setFromTok(TOKENS_LIST[0]); }, [TOKENS_LIST.join()]);
const from = chain.tokenBySym[fromTok];
const to = toTok ? chain.tokenBySym[toTok] : null;
const { data: fromBal } = useTokenBalance(from);
const { data: toBal } = useTokenBalance(to);
const BALANCE = fromBal == null || !from ? null : fmt.toNum(fromBal, from.decimals);
const amountIn = (() => { try { return from && fromAmt ? fmt.parse(fromAmt, from.decimals) : 0n; } catch { return 0n; } })();
useEffect(() => {
if (!chain.read || !from || !to || amountIn <= 0n) { setQuote(null); return; }
let alive = true;
const t = setTimeout(async () => {
try {
const c = chain.read;
const [out, stable] = await c.router.getAmountOut(amountIn, from.address, to.address);
if (out === 0n) throw new Error('No pool for this pair');
let outForUser = out;
try {
const pair = await c.router.pairFor(from.address, to.address, stable);
outForUser = await c.at('Pair', pair).getAmountOutFor.staticCall(amountIn, from.address, chain.account ?? E.ZeroAddress, { from: chain.account ?? E.ZeroAddress });
} catch {}
if (alive) setQuote({ out: outForUser, outNoFee: out, stable, error: null });
} catch (e) {
if (alive) setQuote({ out: 0n, error: window.Chain.decodeError(e).message });
}
}, 250);
return () => { alive = false; clearTimeout(t); };
}, [chain.read, from?.address, to?.address, amountIn.toString(), chain.account, chain.blockNumber]);
const toAmt = quote?.out ? fmt.units(quote.out, to.decimals, 6) : '';
function setPct(p) {
if (BALANCE == null) return;
let a = BALANCE * p / 100;
if (from.native && p === 100) a = Math.max(0, a - 0.01);
setFromAmt(a.toFixed(Math.min(6, from.decimals)));
}
function flip() {
if (!toTok) return;
setFromTok(toTok); setToTok(fromTok);
setFromAmt(toAmt);
}
function onFromChange(v) { setFromAmt(v.replace(/[^0-9.]/g, '')); }
const insufficient = BALANCE != null && parseFloat(fromAmt || '0') > BALANCE;
const canSwap = amountIn > 0n && to && quote?.out > 0n && !insufficient && chain.connected;
function doSwap() {
const minOut = withSlip(quote.out);
const routes = [{ from: from.native ? chain.book.contracts.weth : from.address, to: to.native ? chain.book.contracts.weth : to.address, stable: quote.stable }];
onTx('swap', async (w, stage) => {
const me = chain.account;
if (from.native) return w.router.swapExactETHForTokens(minOut, routes, me, DEADLINE(), { value: amountIn });
await ensureAllowance(w, me, from.address, chain.book.contracts.router, amountIn, stage);
stage('sign');
if (to.native) return w.router.swapExactTokensForETH(amountIn, minOut, routes, me, DEADLINE());
return w.router.swapExactTokensForTokens(amountIn, minOut, routes, me, DEADLINE());
});
}
return (
{/* Token picker overlay */}
{picking && (
setPicking(null)} style={{
position:'fixed', inset:0, background:'rgba(5,5,5,0.82)', zIndex:300,
display:'flex', alignItems:'center', justifyContent:'center',
}}>
e.stopPropagation()}>
SELECT TOKEN
{TOKENS_LIST.filter(t => t !== (picking==='from'?toTok:fromTok)).map(tok => (
))}
)}
{/* header */}
SWAP
{/* FROM */}
FROM
BAL: {BALANCE == null ? '—' : fmt.num(BALANCE, 4)} {fromTok}
onFromChange(e.target.value)} placeholder="0.00"
style={{ background:'none', border:'none', outline:'none', fontFamily:'var(--font-m)', fontSize:26, fontWeight:700, color:'var(--ink)', width:160, minWidth:0 }} />
{/* % row */}
{[10,25,50,75,100].map(p => (
))}
{/* flip */}
{/* TO */}
TO
BAL: {toBal != null && to ? {fmt.num(fmt.toNum(toBal, to.decimals), 4)} {toTok} : '—'}
{toAmt || '0.00'}
{quote?.out > 0n && (
RATE1 {fromTok} = {fmt.num(fmt.toNum(quote.out, to.decimals) / parseFloat(fromAmt), 6)} {toTok}
MIN RECEIVED ({Number(SLIPPAGE_BPS)/100}% SLIPPAGE){fmt.units(withSlip(quote.out), to.decimals, 6)} {toTok}
ROUTE{quote.stable ? 'STABLE' : 'VOLATILE'} POOL
)}
{quote?.error && (
{quote.error}
)}
);
}
/* ── LIQUIDITY SCREEN ────────────────────────────────────────────────── */
function LiquidityScreen({ accent, onSelect, onTx }) {
const isMobile = useMobile();
const chain = useChain();
const [infoPool, setInfoPool] = useState(null);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all');
const { data: pairData, loading, error } = usePairs();
const POOLS = (pairData?.pairs ?? []).map(p => toPool(p, pairData.price, pairData.archPrice)).sort((a, b) => b.tvlNum - a.tvlNum);
const claimable = POOLS.filter(p => p.raw.account_gauge_earned > 0n);
function claimAll() {
onTx('claim-emissions', async (w) => {
const gauges = claimable.map(p => p.gauge);
const ids = [];
for (const p of claimable) {
const g = w.at('VeGauge', p.gauge);
const n = Number(await g.balanceOf(chain.account));
for (let i = 0; i < n; i++) ids.push({ gauge: p.gauge, id: await g.tokenOfOwnerByIndex(chain.account, i) });
}
const gs = ids.map(x => x.gauge), tids = ids.map(x => x.id);
return w.voter.claimRewards(gs, tids);
});
}
const filtered = POOLS.filter(p => {
const q = search.toLowerCase();
const matchQ = p.pair.some(t => t.toLowerCase().includes(q));
const matchF = filter === 'all' || (filter === 'stable' && p.type === 'Stable') || (filter === 'volatile' && p.type === 'Volatile') || (filter === 'mine' && p.myPool !== '—');
return matchQ && matchF;
});
return (
{/* Page header */}
{/* Toolbar */}
⌕
setSearch(e.target.value)} placeholder="Search..."
style={{ background:'none', border:'none', outline:'none', fontFamily:'var(--font-m)', fontSize:12, color:'var(--ink)', width:'100%', letterSpacing:'0.06em' }} />
{(isMobile
? [['all','ALL'],['stable','STB'],['volatile','VOL'],['mine','MINE']]
: [['all','ALL'],['stable','STABLE'],['volatile','VOLATILE'],['mine','MY POOLS']]
).map(([k,l]) => (
))}
{!isMobile && <>
>}
{isMobile &&
}
{/* Table header */}
{(isMobile?['PAIR','','APR','']:['PAIR','TVL','APR','MY POOL','MY STAKE','']).map((h,i) => (
1 : i>1) ? 'right' : 'left' }}>{h}
))}
{/* Rows */}
{filtered.map((pool, i) => (
onSelect(pool)} onInfo={() => setInfoPool(pool)} />
))}
{filtered.length === 0 && (
{loading && !pairData ? 'LOADING POOLS...' : error ? `COULD NOT LOAD POOLS — ${error.toUpperCase()}` : chain.book === null ? 'NOT DEPLOYED ON THIS NETWORK' : 'NO POOLS FOUND'}
)}
{/* Mobile info sheet */}
{infoPool && (
setInfoPool(null)}
stats={[
{ label:'TYPE', value: infoPool.type.toUpperCase(), mono:true },
{ label:'APR', value: infoPool.apr, hi:true },
{ label:'TVL', value: infoPool.tvl },
{ label:'GAUGE', value: infoPool.votes ? 'YES' : 'NO', color: infoPool.votes ? '#4ade80' : 'var(--ink-mute)' },
{ label:'MY POOL', value: infoPool.myPool, color: infoPool.myPool==='—'?'var(--ink-mute)':undefined },
{ label:'MY STAKE', value: infoPool.myStake, color: infoPool.myStake==='—'?'var(--ink-mute)':undefined },
]}
/>
)}
);
}
function PoolRow({ pool, accent, odd, onClick, onInfo }) {
const [hov, setHov] = useState(false);
const isMobile = useMobile();
return (
setHov(true)}
onMouseLeave={() => setHov(false)}
style={{
display:'grid',
gridTemplateColumns: isMobile ? '1fr 26px 58px 24px' : '200px 1fr 100px 110px 110px 40px',
padding: isMobile ? '12px 14px' : '14px 20px',
cursor:'pointer', transition:'background 0.15s',
background: hov ? 'rgba(232,230,225,0.04)' : odd ? 'rgba(232,230,225,0.015)' : 'transparent',
borderBottom:'1px solid rgba(232,230,225,0.04)',
alignItems:'center',
}}>
{pool.pair.join('/')}
{pool.boosted && !isMobile && ⚡BOOST}
{pool.type.toUpperCase()}{pool.boosted && isMobile ? ' ⚡' : ''}
{!pool.votes && (
NO VOTING POWER
)}
{pool.votes && !isMobile && (
VOTING ELIGIBLE
)}
{!isMobile &&
{pool.tvl}
}
{/* ℹ — mobile only, own column */}
{isMobile && (
)}
{/* APR */}
{pool.apr}
{!isMobile &&
{pool.myPool}
}
{!isMobile &&
{pool.myStake}
}
→
);
}
/* ── MANAGE SCREEN ───────────────────────────────────────────────────── */
function ManageScreen({ pool: initial, accent, onBack, onTx, onBoosted }) {
const [tab, setTab] = useState('staking');
const isMobile = useMobile();
const chain = useChain();
const { data: pairData } = usePairs();
const live = pairData?.pairs.find(p => p.pair_address === initial.address);
const pool = live ? toPool(live, pairData.price, pairData.archPrice) : initial;
const { data: positions, loading: posLoading } = useLoad(async (c) => {
if (!chain.account || pool.gauge === E.ZeroAddress) return [];
const rows = await c.positionLens.positionsOf(pool.gauge, chain.account);
const g = c.at('VeGauge', pool.gauge);
const [extBribe, intBribe] = await Promise.all([g.external_bribe(), g.internal_bribe()]);
return rows.map(r => ({
tokenId: r.tokenId, amount: r.amount, latchedInitialFee: Number(r.latchedInitialFee), entry: Number(r.entry), end: Number(r.end),
voted: r.voted, earned: r.earned, exitFeeRate: Number(r.exitFeeRate), feeFree: r.feeFree, netIfWithdrawnNow: r.netIfWithdrawnNow,
withdrawBlocker: r.withdrawBlocker, extBribe, intBribe,
}));
}, [pool.gauge]);
return (
{/* Left panel */}
{/* Pool header */}
{pool.pair.join('/')}
{pool.type.toUpperCase()}
{!pool.votes && NO GAUGE}
{chain.explorerAddr(pool.address) ? (
{fmt.short(pool.address)}
) : (
{fmt.short(pool.address)}
)}
{/* Tab bar */}
{[['deposit','DEPOSIT'],['staking','STAKING'],['positions',`POSITIONS${positions?.length ? ` (${positions.length})` : ''}`]].map(([k,l]) => (
))}
{tab === 'deposit' && }
{tab === 'staking' && }
{tab === 'positions' && }
{/* Right stats */}
{[
{ label:'TVL', value:pool.tvl },
{ label:'APR', value:pool.apr, highlight:true },
{ label:'MY POOL', value:pool.myPool },
{ label:'MY STAKE', value:pool.myStake },
].map(s => (
))}
RESERVES
{pool.raw ? <>{fmt.units(pool.raw.reserve0, pool.raw.token0_decimals, 2)} {pool.pair[0]}
{fmt.units(pool.raw.reserve1, pool.raw.token1_decimals, 2)} {pool.pair[1]}> : '—'}
);
}
function TokenAmountBox({ label, symbol, value, onChange, balance, decimals, onMax, tokens }) {
return (
{label}
);
}
const safeParse = (v, d) => { try { return v ? fmt.parse(v, d) : 0n; } catch { return 0n; } };
function DepositTab({ pool, accent, onTx }) {
const chain = useChain();
const [a0, setA0] = useState(''); const [a1, setA1] = useState('');
const [mode, setMode] = useState('add');
const [lpAmt, setLpAmt] = useState('');
const raw = pool.raw;
const t0 = chain.tokenByAddr[raw?.token0.toLowerCase()] || { address: raw?.token0, symbol: pool.pair[0], decimals: raw?.token0_decimals };
const t1 = chain.tokenByAddr[raw?.token1.toLowerCase()] || { address: raw?.token1, symbol: pool.pair[1], decimals: raw?.token1_decimals };
const weth = chain.book?.contracts.weth.toLowerCase();
const isEth = (t) => t.address?.toLowerCase() === weth;
const { data: b0 } = useTokenBalance(isEth(t0) ? { ...t0, native: true } : t0);
const { data: b1 } = useTokenBalance(isEth(t1) ? { ...t1, native: true } : t1);
const lpBal = raw?.account_lp_balance ?? null;
// Keep the two sides at the pool ratio: editing one side fills the other.
function edit0(v) { setA0(v); if (raw && raw.reserve0 > 0n) { const x = safeParse(v, t0.decimals); setA1(x ? fmt.units(x * raw.reserve1 / raw.reserve0, t1.decimals, 6) : ''); } }
function edit1(v) { setA1(v); if (raw && raw.reserve1 > 0n) { const x = safeParse(v, t1.decimals); setA0(x ? fmt.units(x * raw.reserve0 / raw.reserve1, t0.decimals, 6) : ''); } }
const x0 = safeParse(a0, t0.decimals), x1 = safeParse(a1, t1.decimals), lp = safeParse(lpAmt, 18);
const canAdd = x0 > 0n && x1 > 0n && chain.connected && (b0 == null || x0 <= b0) && (b1 == null || x1 <= b1);
const canRemove = lp > 0n && chain.connected && (lpBal == null || lp <= lpBal);
function add() {
onTx('deposit', async (w, stage) => {
const me = chain.account, router = chain.book.contracts.router;
if (isEth(t0) || isEth(t1)) {
const [tok, ta, eth] = isEth(t0) ? [t1, x1, x0] : [t0, x0, x1];
await ensureAllowance(w, me, tok.address, router, ta, stage);
stage('sign');
return w.router.addLiquidityETH(tok.address, pool.stable, ta, withSlip(ta), withSlip(eth), me, DEADLINE(), { value: eth });
}
await ensureAllowance(w, me, t0.address, router, x0, stage);
await ensureAllowance(w, me, t1.address, router, x1, stage);
stage('sign');
return w.router.addLiquidity(t0.address, t1.address, pool.stable, x0, x1, withSlip(x0), withSlip(x1), me, DEADLINE());
});
}
function remove() {
onTx('withdraw', async (w, stage) => {
const me = chain.account, router = chain.book.contracts.router;
const [q0, q1] = await w.router.quoteRemoveLiquidity(t0.address, t1.address, pool.stable, lp);
await ensureAllowance(w, me, pool.address, router, lp, stage);
stage('sign');
if (isEth(t0) || isEth(t1)) {
const [tok, qt, qe] = isEth(t0) ? [t1, q1, q0] : [t0, q0, q1];
return w.router.removeLiquidityETH(tok.address, pool.stable, lp, withSlip(qt), withSlip(qe), me, DEADLINE());
}
return w.router.removeLiquidity(t0.address, t1.address, pool.stable, lp, withSlip(q0), withSlip(q1), me, DEADLINE());
});
}
return (
{[['add','ADD LIQUIDITY'],['remove','REMOVE']].map(([k,l]) => (
))}
{mode === 'add' ? (
<>
b0 != null && edit0(fmt.units(isEth(t0) && b0 > 10n**16n ? b0 - 10n**16n : b0, t0.decimals, 6))} />
b1 != null && edit1(fmt.units(isEth(t1) && b1 > 10n**16n ? b1 - 10n**16n : b1, t1.decimals, 6))} />
Amounts follow the pool ratio. You receive {pool.pair.join('/')} LP tokens; stake them under STAKING to earn emissions.
>
) : (
<>
lpBal != null && setLpAmt(fmt.units(lpBal, 18, 18))} />
Only unstaked LP can be removed. Staked LP is withdrawn from the position first.
>
)}
);
}
function StakingTab({ pool, accent, onTx, onBoosted }) {
const chain = useChain();
const [amt, setAmt] = useState('');
const lpBal = pool.raw?.account_lp_balance ?? null;
const amount = safeParse(amt, 18);
const hasGauge = pool.gauge && pool.gauge !== E.ZeroAddress;
const canStake = hasGauge && amount > 0n && chain.connected && (lpBal == null || amount <= lpBal);
const { data: exitFee } = useLoad(async (c) => hasGauge ? Number(await c.globals.getGaugeInitialExitFee(pool.gauge, false)) : null, [pool.gauge]);
function stake() {
onTx('stake', async (w, stage) => {
await ensureAllowance(w, chain.account, pool.address, pool.gauge, amount, stage);
stage('sign');
return w.at('VeGauge', pool.gauge).deposit(amount);
});
}
return (
lpBal != null && setAmt(fmt.units(lpBal, 18, 18))} />
{!hasGauge && (
This pool has no gauge yet, so LP cannot be staked here.
)}
{hasGauge && exitFee != null && (
Staking mints a position NFT. Its exit fee starts at {(exitFee/100).toFixed(2)}% and decays over 52 weeks.
)}
);
}
function PositionsTab({ positions, loading, pool, accent, onTx }) {
const chain = useChain();
const [withdrawAmt, setWithdrawAmt] = useState({});
if (!chain.connected) {
return CONNECT A WALLET TO SEE POSITIONS
;
}
if (positions.length === 0) {
return (
{loading ? 'LOADING...' : <>NO ACTIVE POSITIONS
stake to earn rewards>}
);
}
const lpPrice = pool.raw?.lpPrice ?? 0;
const gauge = pool.gauge;
const bribeTokens = chain.tokens.filter(t => !t.native).map(t => t.address);
return (
{positions.map(pos => {
const W = 52;
const weeks = Math.min(W, Math.max(0, (Math.floor(Date.now()/1000) - pos.entry) / (7*86400)));
const feePct = pos.feeFree ? 0 : pos.exitFeeRate / 100;
const lockedPct = pos.latchedInitialFee / 100;
const usd = fmt.toNum(pos.amount, 18) * lpPrice;
const feeNow = usd * feePct / 100;
const feeColor = feePct > 15 ? '#ef4444' : feePct > 8 ? '#ffd47c' : '#4ade80';
const votingLocked = pos.voted;
const id = pos.tokenId.toString();
const wAmt = withdrawAmt[id] ?? '';
const wParsed = safeParse(wAmt, 18);
const withdrawAll = !wAmt || wParsed >= pos.amount;
const amountToWithdraw = withdrawAll ? pos.amount : wParsed;
const blocked = pos.withdrawBlocker !== '0x00000000';
return (
NFT #{id} — {fmt.units(pos.amount, 18, 4)} LP
VALUE: {fmt.usd(usd)} · STAKED {new Date(pos.entry*1000).toISOString().slice(0,10)}
{votingLocked &&
🔒 VOTING — LOCKED
}
{/* exit fee decay */}
EXIT FEE · WEEK {weeks.toFixed(1)} / 52
{pos.feeFree ? 'fee-free gauge' : `rate locked at deposit: ${lockedPct.toFixed(2)}%`}
{[
{ l:'EXIT TODAY', v:`${feePct.toFixed(2)}%`, s:`−${fmt.usd(feeNow)}`, c:feeColor },
{ l:'NET IF WITHDRAWN NOW', v:`${fmt.units(pos.netIfWithdrawnNow, 18, 4)} LP`, s:fmt.usd(fmt.toNum(pos.netIfWithdrawnNow, 18) * lpPrice), c:'var(--ink)' },
].map(m => (
))}
{/* claims */}
CLAIMS — ONE TX EACH
{[
{ l:'LP EMISSIONS', v:`${fmt.units(pos.earned, 18, 4)} ARCH`, tx:'VeGauge.getReward(tokenId)', k:'claim-emissions', enabled: pos.earned > 0n,
run: (w) => w.at('VeGauge', gauge).getReward(pos.tokenId) },
{ l:'BRIBES + TRADING FEES', v:'for voted pools', tx:'Voter.claimFees(bribes, tokens)', k:'claim-bribes', enabled: true,
run: async (w) => {
const n = Number(await w.voter.poolVoteLength(pos.tokenId, gauge));
const pools = await Promise.all(Array.from({ length: n }, (_, i) => w.voter.poolVote(pos.tokenId, gauge, i)));
if (!pools.length) throw new Error('This NFT has not voted, so it has no bribes to claim.');
const gauges = await Promise.all(pools.map(p => w.voter.gauges(p)));
const bribes = [];
for (const g of gauges) { bribes.push(await w.voter.external_bribes(g)); bribes.push(await w.voter.internal_bribes(g)); }
return w.voter.claimFees(bribes, bribes.map(() => bribeTokens));
} },
].map(c => (
{c.v}
))}
{/* exit sequence */}
setWithdrawAmt(s => ({ ...s, [id]: e.target.value.replace(/[^0-9.]/g,'') }))} placeholder={`all (${fmt.units(pos.amount, 18, 4)})`}
style={{ width:120, background:'rgba(232,230,225,0.05)', border:'1px solid rgba(232,230,225,0.1)', padding:'6px 8px', outline:'none', fontFamily:'var(--font-m)', fontSize:10, color:'var(--ink)' }} />
{votingLocked && (
Reset your votes before withdrawing. Full withdrawal burns the position NFT.
)}
{!votingLocked && blocked && (
Withdrawal is currently blocked by the gauge (code {pos.withdrawBlocker}).
)}
);
})}
);
}
/* ── VOTE SCREEN ─────────────────────────────────────────────────────── */
function useVoteData() {
const chain = useChain();
const { data: pairData } = usePairs();
const gaugePools = (pairData?.pairs ?? []).filter(p => p.gauge !== E.ZeroAddress);
const key = gaugePools.map(p => p.pair_address).join();
return useLoad(async (c) => {
if (!pairData) return null;
const { price, archPrice } = pairData;
const bribeTokenAddrs = chain.tokens.filter(t => !t.native).map(t => t.address);
const [totalWeight, voteDelay, epochStart] = await Promise.all([c.voter.totalWeight(), c.voter.VOTE_DELAY(), gaugePools.length ? c.at('Bribe', gaugePools[0].bribe).getNextEpochStart() : 0n]);
const usdOf = (token, amt) => fmt.toNum(amt, chain.tokenByAddr[token.toLowerCase()]?.decimals ?? 18) * (price[token.toLowerCase()] ?? 0);
const pools = await Promise.all(gaugePools.map(async (p) => {
const [weight, ext, intb] = await Promise.all([c.voter.weights(p.pair_address), c.voter.external_bribes(p.gauge), c.voter.internal_bribes(p.gauge)]);
let bribesUsd = 0, feesUsd = 0;
for (const [addr, into] of [[ext, 'b'], [intb, 'f']]) {
if (!addr || addr === E.ZeroAddress) continue;
const b = c.at('Bribe', addr);
const rows = await Promise.all(bribeTokenAddrs.map(t => b.rewardData(t, epochStart).catch(() => null)));
rows.forEach((r, i) => { if (r) { const u = usdOf(bribeTokenAddrs[i], r.rewardsPerEpoch); if (into === 'b') bribesUsd += u; else feesUsd += u; } });
}
return { ...toPool(p, price, archPrice), weight, ext, intb, bribesUsd, feesUsd };
}));
const nfts = [];
if (chain.account) {
for (const p of gaugePools) {
const g = c.at('VeGauge', p.gauge);
const n = Number(await g.balanceOf(chain.account));
for (let i = 0; i < n; i++) {
const id = await g.tokenOfOwnerByIndex(chain.account, i);
const [vp, voted, last, amount] = await Promise.all([g.getVotingPowerView(id), g.voted(id), c.voter.lastVoted(id, p.gauge), g.getAmount(id)]);
const n2 = Number(await c.voter.poolVoteLength(id, p.gauge));
const votedPools = await Promise.all(Array.from({ length: n2 }, (_, k) => c.voter.poolVote(id, p.gauge, k)));
const votedWeights = await Promise.all(votedPools.map(vp2 => c.voter.votes(id, p.gauge, vp2)));
nfts.push({ key: `${p.gauge}:${id}`, gauge: p.gauge, gaugeLabel: `${p.token0_symbol}/${p.token1_symbol}`, tokenId: id, vp, voted, lastVoted: Number(last), amount,
current: Object.fromEntries(votedPools.map((vp2, k) => [vp2.toLowerCase(), votedWeights[k]])) });
}
}
}
if (chain.account) {
const [keys, deps] = await c.custody.getUserDeposits(chain.account);
for (let i = 0; i < keys.length; i++) {
const d = deps[i];
if (await c.custody.nftDelegated(keys[i])) continue;
const p = gaugePools.find(gp => gp.gauge.toLowerCase() === d.underlyingVotingSource.toLowerCase());
if (!p) continue;
const g = c.at('VeGauge', p.gauge);
const id = d.tokenId;
const [vp, voted, last] = await Promise.all([g.getVotingPowerView(id), g.voted(id), c.voter.lastVoted(id, p.gauge)]);
const n2 = Number(await c.voter.poolVoteLength(id, p.gauge));
const votedPools = await Promise.all(Array.from({ length: n2 }, (_, k) => c.voter.poolVote(id, p.gauge, k)));
const votedWeights = await Promise.all(votedPools.map(vp2 => c.voter.votes(id, p.gauge, vp2)));
nfts.push({ key: `custody:${keys[i]}`, nftKey: keys[i], gauge: p.gauge, gaugeLabel: `${p.token0_symbol}/${p.token1_symbol} · IN CUSTODY`, tokenId: id, vp, voted, lastVoted: Number(last), amount: d.underlyingAmount,
current: Object.fromEntries(votedPools.map((vp2, k) => [vp2.toLowerCase(), votedWeights[k]])) });
}
}
const totalVp = nfts.reduce((a, n) => a + n.vp, 0n);
return { pools, nfts, totalWeight, voteDelay: Number(voteDelay), totalVp,
totalBribes: pools.reduce((a, p) => a + p.bribesUsd, 0), totalFees: pools.reduce((a, p) => a + p.feesUsd, 0) };
}, [key, pairData?.archPrice]);
}
function VoteScreen({ accent, onTx }) {
const isMobile = useMobile();
const chain = useChain();
const [infoVote, setInfoVote] = useState(null);
const epoch = useEpoch();
const ep = useCountdown(epoch.endsAt);
const { data, loading, error } = useVoteData();
const [votes, setVotes] = useState({});
const [search, setSearch] = useState('');
const [nftOpen, setNftOpen] = useState(false);
const [selKey, setSelKey] = useState(null);
const nfts = data?.nfts ?? [];
const sel = nfts.find(n => n.key === selKey) ?? nfts[0] ?? null;
const selVP = sel ? fmt.toNum(sel.vp, 18) : 0;
const TOTAL_VP = selVP;
useEffect(() => {
if (!sel) return;
const cur = {};
for (const [pool, w] of Object.entries(sel.current)) cur[pool] = fmt.toNum(w, 18).toFixed(0);
setVotes(cur);
}, [sel?.key]);
const totalAlloc = Object.values(votes).reduce((a,v) => a+(parseFloat(v)||0), 0);
const vpPct = TOTAL_VP > 0 ? Math.min((totalAlloc/TOTAL_VP)*100, 100) : 0;
const anyVoted = totalAlloc > 0;
const nowSec = Math.floor(Date.now()/1000);
const cooldownLeft = sel ? Math.max(0, sel.lastVoted + (data?.voteDelay ?? 0) - nowSec) : 0;
const canCast = anyVoted && sel && chain.connected && cooldownLeft === 0;
function setVote(id, val) { setVotes(p => ({...p, [id]: val.replace(/[^0-9.]/g,'')})); }
function setMax(id) {
const used = Object.entries(votes).reduce((a,[k,v]) => k===id ? a : a+(parseFloat(v)||0), 0);
setVote(id, Math.max(0, TOTAL_VP - used).toFixed(0));
}
function cast() {
const entries = Object.entries(votes).filter(([, v]) => parseFloat(v) > 0);
const pools = entries.map(([p]) => p), weights = entries.map(([, v]) => fmt.parse(v, 18));
onTx('vote', (w) => sel.nftKey ? w.custody.selfVote(sel.nftKey, pools, weights) : w.voter.vote(sel.tokenId, sel.gauge, pools, weights));
}
function resetVotes() { onTx('reset', (w) => sel.nftKey ? w.custody.selfResetVotes(sel.nftKey) : w.voter.reset(sel.tokenId, sel.gauge)); }
const totalWeightNum = data ? fmt.toNum(data.totalWeight, 18) : 0;
const weeklyEmissionUsd = data ? data.pools.reduce((a, p) => a + fmt.toNum(p.raw.emissions, 18), 0) : 0;
const VOTE_POOLS = (data?.pools ?? []).map(p => {
const w = fmt.toNum(p.weight, 18);
const rewardsUsd = p.bribesUsd + p.feesUsd;
const per1k = w > 0 ? rewardsUsd / w * 1000 : rewardsUsd > 0 ? Infinity : 0;
const voteApr = w > 0 && data ? (rewardsUsd * 52) / (w * (data.pools[0]?.raw.lpPrice ?? 1)) * 100 : 0;
return { id: p.address.toLowerCase(), address: p.address, pair: p.pair, name: p.pair.join('/'), sub: p.type, totalVotes: w,
pct: totalWeightNum > 0 ? (w / totalWeightNum * 100) : 0, apr: voteApr, rewards: rewardsUsd, estimPer1k: per1k, bribesUsd: p.bribesUsd, feesUsd: p.feesUsd, gauge: p.gauge };
}).sort((a, b) => b.totalVotes - a.totalVotes);
const filtered = VOTE_POOLS.filter(p => {
const q = search.toLowerCase();
return p.name.toLowerCase().includes(q) || p.sub.toLowerCase().includes(q) || p.pair.some(t=>t.toLowerCase().includes(q));
});
const avgApr = VOTE_POOLS.length ? VOTE_POOLS.reduce((a, p) => a + (isFinite(p.apr) ? p.apr : 0), 0) / VOTE_POOLS.length : 0;
return (
{/* Page header */}
{!isMobile && sel && sel.voted &&
}
{/* Stats bar */}
{[
{ label:'YOUR VOTING POWER', value: data ? fmt.num(fmt.toNum(data.totalVp, 18), 2) : '—', hi:false, mono:false },
{ label:'AVG VOTE APR', value: data ? fmt.pct(avgApr) : '—', hi:true, mono:false },
{ label:`EPOCH ${epoch.number ?? '—'} ENDS`, value: ep.ready ? `${ep.d}d ${String(ep.h).padStart(2,'0')}h ${String(ep.m).padStart(2,'0')}m ${String(ep.s).padStart(2,'0')}s` : '—', hi:false, mono:true },
{ label:'BRIBES NEXT EPOCH', value: data ? fmt.usd(data.totalBribes) : '—', hi:false, mono:false },
{ label:'FEES NEXT EPOCH', value: data ? fmt.usd(data.totalFees) : '—', hi:false, mono:false },
].map((st,i) => (
0) ? '1px solid rgba(232,230,225,0.07)' : 'none',
borderTop: (isMobile && i>=2) ? '1px solid rgba(232,230,225,0.07)' : 'none',
}}>
{st.label}
{st.value}
))}
{/* Main vote card */}
{/* NFT Dropdown */}
{nftOpen && (
setNftOpen(false)} style={{ position:'fixed', inset:0, zIndex:300 }}>
e.stopPropagation()} style={{
position:'absolute', top:58, right:20,
background:'#0a0807', border:`1px solid ${accent}55`,
padding:'8px 0', minWidth:220, zIndex:301,
}}>
{nfts.map(n => (
))}
{nfts.length === 0 &&
No staked positions in your wallet
}
)}
{/* Toolbar */}
⌕
setSearch(e.target.value)} placeholder="Search..."
style={{ background:'none', border:'none', outline:'none', fontFamily:'var(--font-m)', fontSize:12, color:'var(--ink)', width:'100%', letterSpacing:'0.06em' }} />
{/* Table header */}
{(isMobile ? ['POOL','APR','YOUR VOTE'] : ['POOL','↓ TOTAL VOTES','APR','REWARDS','ESTIMATION','YOUR VOTES','']).map((h,i) => (
0 ? 'center' : 'left',
}}>{h}
))}
{/* Vote rows */}
{filtered.map((pool,i) => (
setVote(pool.id, v)}
onMax={() => setMax(pool.id)}
totalVP={TOTAL_VP}
onInfo={() => setInfoVote(pool)}
/>
))}
{filtered.length === 0 && (
{loading && !data ? 'LOADING GAUGES...' : error ? `COULD NOT LOAD — ${error.toUpperCase()}` : 'NO GAUGES FOUND'}
)}
{/* Bottom: VP bar + cast */}
{/* VP bar */}
VotingPower used: {vpPct.toFixed(2)}%
Voted this epoch: {sel?.voted ? 'Yes' : 'No'}
{fmt.num(totalAlloc, 0)} / {fmt.num(TOTAL_VP, 0)} VP
Weights are relative: the numbers you enter are split proportionally across your NFT's full voting power. Casting again replaces the previous vote.
{/* Mobile info sheet */}
{infoVote && (
setInfoVote(null)}
stats={[
{ label:'TOTAL VOTES', value: fmt.num(infoVote.totalVotes, 0), sub: infoVote.pct.toFixed(1)+'%' },
{ label:'APR', value: fmt.pct(infoVote.apr), hi:true, color:'#4ade80' },
{ label:'REWARDS', value: fmt.usd(infoVote.rewards) },
{ label:'EST / 1K', value: isFinite(infoVote.estimPer1k) ? fmt.usd(infoVote.estimPer1k) : '∞', sub:'per 1K votes' },
{ label:'BRIBES', value: fmt.usd(infoVote.bribesUsd), mono:true },
{ label:'FEES', value: fmt.usd(infoVote.feesUsd), mono:true },
]}
/>
)}
);
}
function VoteRow({ pool, accent, odd, voteVal, onVoteChange, onMax, totalVP, onInfo }) {
const [hov, setHov] = useState(false);
const isMobile = useMobile();
const myVote = parseFloat(voteVal) || 0;
const myPct = myVote && totalVP ? (myVote/totalVP*100).toFixed(1)+'%' : '—';
const voted = myVote > 0;
return (
setHov(true)} onMouseLeave={()=>setHov(false)}
style={{
display:'grid',
gridTemplateColumns: isMobile ? '1fr 60px 110px' : '1fr 110px 70px 80px 120px 100px 130px',
padding: isMobile ? '11px 14px' : '13px 18px', alignItems:'center',
transition:'background 0.14s',
background: hov ? 'rgba(232,230,225,0.04)' : odd ? 'rgba(232,230,225,0.016)' : 'transparent',
borderBottom:'1px solid rgba(232,230,225,0.04)',
}}>
{/* Pool */}
{isMobile && (
)}
{!isMobile &&
{fmt.num(pool.totalVotes, 0)}
{pool.pct.toFixed(1)}%
}
{fmt.pct(pool.apr)}
{!isMobile &&
{fmt.usd(pool.rewards)}
}
{!isMobile &&
{isFinite(pool.estimPer1k) ? fmt.usd(pool.estimPer1k) : '∞'}
PER 1K VOTES
}
{!isMobile &&
{voted ? fmt.num(myVote, 0) : '—'}
{myPct}
}
onVoteChange(e.target.value)}
placeholder="0"
inputMode="decimal"
style={{
flex:1, background:'rgba(232,230,225,0.05)',
border:`1px solid ${voted ? accent+'88' : 'rgba(232,230,225,0.1)'}`,
padding:'6px 8px', outline:'none',
fontFamily:'var(--font-m)', fontSize:12, color:'var(--ink)',
width:0, minWidth:0, transition:'border-color 0.15s',
}}
/>
);
}
Object.assign(window, { TokenIcon, TokenPair, Nav, SwapScreen, LiquidityScreen, ManageScreen, VoteScreen, Card, useMobile, useCountdown, toPool, TokenAmountBox, safeParse, DEADLINE, withSlip });
/* ── MOBILE INFO SHEET ───────────────────────────────────────────────── */
function MobileInfoSheet({ title, tokens, stats, onClose, accent }) {
return (
e.stopPropagation()} style={{
position:'relative', width:'100%',
background:'rgba(8,6,5,0.99)', borderTop:`2px solid ${accent}`,
padding:'20px 18px 36px', animation:'fadeUp 0.2s forwards',
}}>
{/* accent line */}
{/* Header */}
{/* Stats grid */}
{stats.map((s, i) => (
{s.label}
{s.value}
{s.sub &&
{s.sub}
}
))}
);
}
Object.assign(window, { MobileInfoSheet });