diff --git a/docs/user/vecrv/boosting.md b/docs/user/vecrv/boosting.md index 381a2b7e..d75d5867 100644 --- a/docs/user/vecrv/boosting.md +++ b/docs/user/vecrv/boosting.md @@ -5,7 +5,7 @@ sidebar_label: Boosting Rewards --- import ThemedImage from '@theme/ThemedImage'; -import ButtonGrid from '@site/src/components/ButtonGrid'; +import BoostCalculator from '@site/src/components/BoostCalculator'; # Boosting CRV Rewards @@ -13,7 +13,17 @@ import ButtonGrid from '@site/src/components/ButtonGrid'; This guide assumes you have already provided liquidity and are currently staking LP tokens on the DAO gauge. If you haven't done this yet, see our [DEX liquidity provision guide](../dex/liquidity.md) or [Llamalend supplying guide](../llamalend/supplying.md). ::: -One of the main incentives for holding CRV is the ability to boost rewards on provided liquidity. By locking CRV for veCRV, you not only gain voting power in the DAO and revenue share, but also earn a boost of up to **2.5x** on your liquidity rewards across Curve pools and lending markets. +Locking CRV into veCRV can improve the return profile of a Curve position in several ways. The boost calculator at the bottom of this article helps estimate the CRV reward boost for a specific gauge, while also showing an estimated crvUSD revenue share based on recent veCRV holder revenue. + +## What Locking CRV Unlocks + +Locking CRV gives users three protocol-level benefits: + +- **Boosted CRV rewards:** veCRV can increase CRV rewards on staked LP positions by up to **2.5x**, depending on the gauge and the user's share of veCRV. +- **crvUSD revenue share:** veCRV holders receive a share of crvUSD protocol revenue. The calculator estimates this using recent DefiLlama holders-revenue data. +- **Governance and gauge voting power:** veCRV gives voting power in Curve DAO governance and gauge weight votes, which influence where CRV emissions are directed. + +These benefits are related but separate. A user can use veCRV to boost liquidity rewards in a gauge and still receive protocol revenue share from veCRV ownership. ## How Rewards Are Displayed @@ -34,16 +44,15 @@ In Curve's interface, rewards are always displayed as a range showing the differ Only **CRV rewards** are boosted by veCRV. External reward tokens (like tokens from other protocols) are distributed solely based on your liquidity share and are not affected by veCRV holdings. ::: -## Step 1: Calculate Required veCRV +## Step 1: Review Required veCRV The first step to getting rewards boosted is to determine how much veCRV you need. Each gauge has different requirements, meaning some pools are easier to boost than others. This depends on: -- The amount of veCRV others have locked -- The total value locked (TVL) in the gauge -- Your own liquidity position -Use the [boost calculator](https://dao-old.curve.finance/minter/calc) to determine your specific requirements: +- The amount of veCRV others have locked +- The total value staked in the gauge +- Your future share of staked LP tokens in that gauge - +Use the calculator below to compare how much veCRV you have, or plan to lock, against the amount needed for maximum boost. ## Step 2: Lock CRV for veCRV @@ -78,22 +87,28 @@ Boosts are only updated when you make a withdrawal, deposit, or claim from a liq Your boost multiplier can be between 1 and 2.5. This means if you have a 2.5x boost and deposit \$10,000, your rewards are calculated as if you deposited $25,000. It depends on the following factors: - * **Your veCRV balance:** More veCRV means a higher potential boost. - * **The amount of liquidity you provide:** Your boost is calculated relative to your liquidity. - * **The total veCRV and liquidity in the gauge:** The boost mechanism balances your veCRV power against everyone else's in the Pool or Lending market's gauge. +- **Your veCRV balance:** More veCRV means a higher potential boost. +- **Your future share of staked LP tokens in the gauge:** Your boost is calculated relative to the share of gauge-staked liquidity your position will represent after staking. +- **The total veCRV and staked liquidity in the gauge:** The boost mechanism balances your veCRV power against everyone else's staked LP tokens in the Pool or Lending market's gauge. By boosting, you're essentially getting a larger share of the new CRV tokens distributed. This incentivizes users to lock CRV, which in turn strengthens the Curve DAO's governance and promotes long-term alignment with the protocol. ### The Boost Formula $$ -B = \min\left(2.5, 0.4 + 2.5 \times \frac{\text{veCRV}_\text{user}}{\text{veCRV}_\text{total}} \times \frac{\text{Value}_\text{pool}}{\text{Value}_\text{user}}\right) +B = \min\left(2.5, 1 + 1.5 \times \frac{\frac{\text{veCRV}_\text{user}}{\text{veCRV}_\text{total}}}{\frac{\text{Value}_\text{user}}{\text{Value}_\text{gauge}}}\right) $$ **Formula Variables:** - **$B$** = Your rewards boost (capped at 2.5x maximum) - **$\text{Value}_\text{user}$** = Your deposited value in USD -- **$\text{Value}_\text{pool}$** = Total value in the pool's reward gauge in USD +- **$\text{Value}_\text{gauge}$** = Future total value staked in the reward gauge in USD, including your deposit if it is a new position - **$\text{veCRV}_\text{user}$** = Your veCRV amount (vote weight) -- **$\text{veCRV}_\text{total}$** = Total veCRV in the system ([check current amount](https://dao-old.curve.finance/minter/calc)) +- **$\text{veCRV}_\text{total}$** = Total veCRV in the system + +## Estimate Your Boost And Revenue Share + +Use this calculator to estimate the boost for a specific pool gauge before connecting a wallet. It compares your share of all veCRV with your future share of the selected gauge's staked LP tokens. It also estimates crvUSD revenue share from recent DefiLlama holders revenue. This revenue estimate is not guaranteed yield. + + diff --git a/src/components/BoostCalculator/index.jsx b/src/components/BoostCalculator/index.jsx new file mode 100644 index 00000000..235dacbe --- /dev/null +++ b/src/components/BoostCalculator/index.jsx @@ -0,0 +1,1184 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ethers } from 'ethers'; +import styles from './styles.module.css'; + +const GAUGES_URL = 'https://prices.curve.finance/v1/dao/gauges/overview'; +const CRV_PRICE_URL = 'https://prices.curve.finance/v1/usd_price/ethereum/0xD533a949740bb3306d119CC777fa900bA034cd52'; +const VECRV_LOCKERS_URL = 'https://prices.curve.finance/v1/dao/lockers/1000'; +const DEFILLAMA_HOLDERS_REVENUE_URL = 'https://api.llama.fi/summary/fees/crv-usd?dataType=dailyHoldersRevenue'; +const RPC_URL = 'https://ethereum-rpc.publicnode.com/'; +const VECRV_ADDRESS = '0x5f3b5DfEb7B28CDbD7FAba78963EE202a494e2A2'; +const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'; + +const MULTICALL3_ABI = [ + 'function aggregate3((address target, bool allowFailure, bytes callData)[] calls) view returns ((bool success, bytes returnData)[])', +]; + +const VECRV_ABI = [ + 'function totalSupply() view returns (uint256)', + 'function balanceOf(address) view returns (uint256)', +]; + +const GAUGE_ABI = [ + 'function balanceOf(address) view returns (uint256)', + 'function totalSupply() view returns (uint256)', +]; + +const DEFAULTS = { + depositLpTokens: '', + depositValue: '10000', + poolValue: '', + totalVecrv: '', + userVecrv: '', + crvAmount: '', + lockYears: '4', +}; + +const SORT_OPTIONS = { + stakedTvl: 'Staked TVL', + name: 'Name', + poolTvl: 'Pool TVL', + poolAddress: 'Pool address', + gaugeAddress: 'Gauge address', + created: 'Creation date', +}; + +function toNumber(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function toTimestamp(value) { + const number = Number(value); + if (Number.isFinite(number) && number > 0) return number; + + const parsedDate = Date.parse(value); + return Number.isFinite(parsedDate) ? parsedDate : 0; +} + +function formatUsd(value, maximumFractionDigits = 0) { + const number = toNumber(value); + if (!number) return '$0'; + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: Math.abs(number) >= 1_000_000 ? 'compact' : 'standard', + maximumFractionDigits, + }).format(number); +} + +function formatAmount(value, maximumFractionDigits = 2) { + const number = toNumber(value); + if (!number) return '0'; + + return new Intl.NumberFormat('en-US', { + notation: Math.abs(number) >= 1_000_000 ? 'compact' : 'standard', + maximumFractionDigits, + }).format(number); +} + +function formatPercent(value, maximumFractionDigits = 4) { + const number = toNumber(value); + if (!number) return '0%'; + + return new Intl.NumberFormat('en-US', { + style: 'percent', + maximumFractionDigits, + }).format(number); +} + +function shortAddress(address) { + if (!address) return 'No address'; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function parseVecrvWeight(value) { + const number = Number(value); + return Number.isFinite(number) ? number / 1e18 : 0; +} + +function getGaugeTokens(gauge) { + if (!gauge?.symbol) return []; + return gauge.symbol.split('/').map((token) => token.trim()).filter(Boolean); +} + +function getGaugeValueUpdates(gauge, currentValues) { + if (!gauge) return {}; + + const depositValue = toNumber(currentValues.depositValue); + + return { + depositLpTokens: gauge.lpTokenPrice > 0 && depositValue > 0 + ? String(depositValue / gauge.lpTokenPrice) + : currentValues.depositLpTokens, + poolValue: String(Math.round(gauge.stakedTvl || 0)), + }; +} + +function isEthereumGauge(gauge) { + const chain = gauge?.pool?.chain || gauge?.market?.chain || gauge?.side_chain; + if (!chain) return true; + return String(chain).toLowerCase() === 'ethereum'; +} + +function normalizeGauge(gauge) { + const gaugeAddress = gauge?.effective_address || gauge?.address || ''; + const tokenSymbols = Array.isArray(gauge?.tokens) + ? gauge.tokens.map((token) => token?.symbol).filter(Boolean).join(' / ') + : ''; + const name = gauge?.pool?.name || gauge?.market?.name || gauge?.name || 'Curve gauge'; + const lpTokenPrice = toNumber(gauge?.lp_token_price, 0); + const poolAddress = gauge?.pool?.address || gauge?.pool_address || gauge?.lp_token || ''; + const poolTvl = toNumber(gauge?.pool?.usd_total || gauge?.pool?.tvl_usd || gauge?.pool?.tvl || gauge?.market?.usd_total, 0); + const createdAt = toTimestamp(gauge?.pool?.created_at || gauge?.pool?.creation_date || gauge?.created_at || gauge?.creation_date); + + return { + id: gaugeAddress || gauge?.lp_token || name, + address: gaugeAddress, + originalAddress: gauge?.address || '', + poolAddress, + name, + symbol: tokenSymbols, + poolTvl, + stakedTvl: 0, + gaugeSupply: 0, + lpTokenPrice, + createdAt, + crvAprBase: toNumber(gauge?.crv_apr_base, 0), + crvAprBoosted: toNumber(gauge?.crv_apr_boosted, 0), + }; +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Request failed: ${response.status}`); + return response.json(); +} + +function decodeBigint(iface, method, result) { + if (!result?.success || result.returnData === '0x') return null; + return iface.decodeFunctionResult(method, result.returnData)[0]; +} + +async function fetchTotalVecrv() { + const provider = new ethers.JsonRpcProvider(RPC_URL, 1, { staticNetwork: true }); + const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider); + const vecrvIface = new ethers.Interface(VECRV_ABI); + const [result] = await multicall.aggregate3([ + { + target: VECRV_ADDRESS, + allowFailure: false, + callData: vecrvIface.encodeFunctionData('totalSupply'), + }, + ]); + + return Number(ethers.formatEther(decodeBigint(vecrvIface, 'totalSupply', result) || 0n)); +} + +async function fetchGaugeStakedTvls(gauges) { + const provider = new ethers.JsonRpcProvider(RPC_URL, 1, { staticNetwork: true }); + const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider); + const gaugeIface = new ethers.Interface(GAUGE_ABI); + const chunkSize = 120; + const enrichedGauges = []; + + for (let index = 0; index < gauges.length; index += chunkSize) { + const chunk = gauges.slice(index, index + chunkSize); + const results = await multicall.aggregate3(chunk.map((gauge) => ({ + target: gauge.address, + allowFailure: true, + callData: gaugeIface.encodeFunctionData('totalSupply'), + }))); + + enrichedGauges.push(...chunk.map((gauge, chunkIndex) => { + const gaugeSupply = Number(ethers.formatEther(decodeBigint(gaugeIface, 'totalSupply', results[chunkIndex]) || 0n)); + const stakedTvl = gauge.lpTokenPrice > 0 && gaugeSupply > 0 ? gaugeSupply * gauge.lpTokenPrice : 0; + + return { + ...gauge, + gaugeSupply, + stakedTvl, + }; + })); + } + + return enrichedGauges.sort((a, b) => b.stakedTvl - a.stakedTvl); +} + +async function fetchVecrvLeaderboard() { + const response = await fetchJson(VECRV_LOCKERS_URL); + const users = Array.isArray(response?.users) ? response.users : []; + + return users + .filter((user) => user?.user && user.user !== 'Others') + .map((user, index) => ({ + address: user.user.toLowerCase(), + rank: index + 1, + vecrv: parseVecrvWeight(user.weight), + })) + .filter((user) => user.vecrv > 0); +} + +async function fetchVecrvRevenue() { + const response = await fetchJson(DEFILLAMA_HOLDERS_REVENUE_URL); + + return { + total7d: toNumber(response?.total7d), + total30d: toNumber(response?.total30d), + }; +} + +export default function BoostCalculator() { + const selectorButtonRef = useRef(null); + const pickerRef = useRef(null); + const searchInputRef = useRef(null); + const [gauges, setGauges] = useState([]); + const [selectedGaugeId, setSelectedGaugeId] = useState(''); + const [poolSearch, setPoolSearch] = useState(''); + const [status, setStatus] = useState('loading'); + const [notice, setNotice] = useState(''); + const [crvPrice, setCrvPrice] = useState(0); + const [mode, setMode] = useState('crv'); + const [address, setAddress] = useState(''); + const [addressStatus, setAddressStatus] = useState('idle'); + const [addressError, setAddressError] = useState(''); + const [addressData, setAddressData] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + const [leaderboardStatus, setLeaderboardStatus] = useState('loading'); + const [revenueData, setRevenueData] = useState(null); + const [revenueStatus, setRevenueStatus] = useState('loading'); + const [positionMode, setPositionMode] = useState('new'); + const [showEmptyGauges, setShowEmptyGauges] = useState(false); + const [isGaugePickerOpen, setIsGaugePickerOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState(''); + const [gaugeSort, setGaugeSort] = useState('stakedTvl'); + const [values, setValues] = useState(DEFAULTS); + + useEffect(() => { + let ignore = false; + + async function loadCalculatorData() { + try { + const [gaugesResponse, priceResponse, totalVecrvResponse] = await Promise.all([ + fetchJson(GAUGES_URL), + fetchJson(CRV_PRICE_URL).catch(() => null), + fetchTotalVecrv().catch(() => null), + ]); + + if (ignore) return; + + const rawGauges = gaugesResponse?.data?.gauges || gaugesResponse?.gauges || []; + const activeGauges = rawGauges + .filter((gauge) => !gauge?.is_killed && isEthereumGauge(gauge) && (gauge?.effective_address || gauge?.address)) + .map(normalizeGauge); + + if (!activeGauges.length) throw new Error('No active Ethereum gauges found.'); + + const gaugesWithStakedTvl = await fetchGaugeStakedTvls(activeGauges); + if (ignore) return; + + setGauges(gaugesWithStakedTvl); + setSelectedGaugeId(gaugesWithStakedTvl[0].id); + setValues((current) => ({ + ...current, + ...getGaugeValueUpdates(gaugesWithStakedTvl[0], current), + totalVecrv: totalVecrvResponse ? String(totalVecrvResponse) : current.totalVecrv, + })); + setCrvPrice(toNumber(priceResponse?.data?.usd_price, 0)); + setStatus('ready'); + } catch (error) { + if (ignore) return; + setStatus('fallback'); + setNotice('Live staked gauge TVL is unavailable. You can still enter the value staked in the gauge and total veCRV manually.'); + } + } + + loadCalculatorData(); + return () => { + ignore = true; + }; + }, []); + + useEffect(() => { + let ignore = false; + + async function loadRevenueData() { + try { + const nextRevenueData = await fetchVecrvRevenue(); + if (ignore) return; + setRevenueData(nextRevenueData); + setRevenueStatus(nextRevenueData.total7d || nextRevenueData.total30d ? 'ready' : 'unavailable'); + } catch (error) { + if (ignore) return; + setRevenueStatus('unavailable'); + } + } + + loadRevenueData(); + return () => { + ignore = true; + }; + }, []); + + useEffect(() => { + if (!isGaugePickerOpen) return undefined; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + window.setTimeout(() => searchInputRef.current?.focus(), 0); + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isGaugePickerOpen]); + + useEffect(() => { + let ignore = false; + + async function loadLeaderboard() { + try { + const nextLeaderboard = await fetchVecrvLeaderboard(); + if (ignore) return; + setLeaderboard(nextLeaderboard); + setLeaderboardStatus(nextLeaderboard.length ? 'ready' : 'unavailable'); + } catch (error) { + if (ignore) return; + setLeaderboardStatus('unavailable'); + } + } + + loadLeaderboard(); + return () => { + ignore = true; + }; + }, []); + + const selectedGauge = useMemo( + () => gauges.find((gauge) => gauge.id === selectedGaugeId) || null, + [gauges, selectedGaugeId], + ); + + const tokenOptions = useMemo(() => { + const tokenCounts = new Map(); + + gauges.forEach((gauge) => { + getGaugeTokens(gauge).forEach((token) => { + tokenCounts.set(token, (tokenCounts.get(token) || 0) + 1); + }); + }); + + return Array.from(tokenCounts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([token]) => token); + }, [gauges]); + + const filteredGauges = useMemo(() => { + const query = poolSearch.trim().toLowerCase(); + const availableGauges = showEmptyGauges + ? gauges + : gauges.filter((gauge) => gauge.stakedTvl > 0); + const token = selectedToken.trim().toLowerCase(); + const matchedGauges = availableGauges.filter((gauge) => { + const matchesToken = token + ? getGaugeTokens(gauge).some((gaugeToken) => gaugeToken.toLowerCase() === token) + : true; + if (!matchesToken) return false; + if (!query) return true; + + const searchable = [ + gauge.name, + gauge.symbol, + gauge.address, + gauge.originalAddress, + gauge.poolAddress, + formatUsd(gauge.stakedTvl), + formatUsd(gauge.poolTvl), + ].join(' ').toLowerCase(); + + return searchable.includes(query); + }); + + return [...matchedGauges].sort((a, b) => { + if (gaugeSort === 'name') return a.name.localeCompare(b.name); + if (gaugeSort === 'poolTvl') return (b.poolTvl || b.stakedTvl) - (a.poolTvl || a.stakedTvl); + if (gaugeSort === 'poolAddress') return (a.poolAddress || '').localeCompare(b.poolAddress || ''); + if (gaugeSort === 'gaugeAddress') return (a.address || '').localeCompare(b.address || ''); + if (gaugeSort === 'created') return (b.createdAt || 0) - (a.createdAt || 0) || b.stakedTvl - a.stakedTvl; + return b.stakedTvl - a.stakedTvl; + }); + }, [gauges, gaugeSort, poolSearch, selectedToken, showEmptyGauges]); + + useEffect(() => { + if (!filteredGauges.length || filteredGauges.some((gauge) => gauge.id === selectedGaugeId)) return; + + const nextGauge = filteredGauges[0]; + setSelectedGaugeId(nextGauge.id); + setAddressData(null); + setAddressError(''); + setValues((current) => ({ + ...current, + ...getGaugeValueUpdates(nextGauge, current), + })); + }, [filteredGauges, selectedGaugeId]); + + const lockYears = Math.min(Math.max(toNumber(values.lockYears, 4), 0.01), 4); + const depositLpTokens = toNumber(values.depositLpTokens); + const depositValue = selectedGauge?.lpTokenPrice > 0 + ? depositLpTokens * selectedGauge.lpTokenPrice + : toNumber(values.depositValue); + const currentStakedGaugeTvl = toNumber(values.poolValue); + const futureStakedGaugeTvl = positionMode === 'new' + ? currentStakedGaugeTvl + depositValue + : currentStakedGaugeTvl; + const totalVecrv = toNumber(values.totalVecrv); + const userVecrv = mode === 'vecrv' + ? toNumber(values.userVecrv) + : toNumber(values.crvAmount) * (lockYears / 4); + const futureTotalVecrv = mode === 'crv' ? totalVecrv + userVecrv : totalVecrv; + const vecrvShare = futureTotalVecrv > 0 ? userVecrv / futureTotalVecrv : 0; + const futureGaugeShare = futureStakedGaugeTvl > 0 ? depositValue / futureStakedGaugeTvl : 0; + const leaderboardMatch = addressData?.address + ? leaderboard.find((holder) => holder.address === addressData.address.toLowerCase()) + : null; + const estimatedLeaderboardRank = userVecrv > 0 && leaderboard.length + ? leaderboard.filter((holder) => holder.vecrv > userVecrv).length + 1 + : 0; + const displayedLeaderboardRank = leaderboardMatch?.rank || estimatedLeaderboardRank; + const isOutsideFetchedLeaderboard = userVecrv > 0 + && leaderboard.length > 0 + && estimatedLeaderboardRank > leaderboard.length; + + const boost = futureGaugeShare > 0 && totalVecrv > 0 + ? Math.min(2.5, Math.max(1, 1 + 1.5 * (vecrvShare / futureGaugeShare))) + : 1; + const minVecrv = futureGaugeShare > 0 && totalVecrv > 0 + ? totalVecrv * futureGaugeShare + : 0; + const effectiveDeposit = depositValue * boost; + const remainingVecrv = Math.max(minVecrv - userVecrv, 0); + const crvForMaxBoost = lockYears > 0 ? minVecrv / (lockYears / 4) : 0; + const remainingCrvForMaxBoost = lockYears > 0 ? remainingVecrv / (lockYears / 4) : 0; + const crvInputCost = mode === 'crv' && crvPrice > 0 ? toNumber(values.crvAmount) * crvPrice : 0; + const crvForMaxBoostCost = crvPrice > 0 ? crvForMaxBoost * crvPrice : 0; + const weeklyRevenueEstimate = revenueData?.total7d ? vecrvShare * revenueData.total7d : 0; + const monthlyRevenueEstimate = revenueData?.total30d ? vecrvShare * revenueData.total30d : 0; + const hasRevenueData = revenueStatus === 'ready'; + + function updateValue(key, value) { + setValues((current) => ({ ...current, [key]: value })); + } + + function closeGaugePicker({ restoreFocus = true } = {}) { + setIsGaugePickerOpen(false); + if (restoreFocus) { + window.setTimeout(() => selectorButtonRef.current?.focus(), 0); + } + } + + function handleGaugePickerKeyDown(event) { + if (event.key === 'Escape') { + event.preventDefault(); + closeGaugePicker(); + return; + } + + if (event.key !== 'Tab' || !pickerRef.current) return; + + const focusableElements = Array.from(pickerRef.current.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])', + )).filter((element) => element.offsetParent !== null); + if (!focusableElements.length) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + + function handleGaugeChange(id) { + const nextGauge = gauges.find((gauge) => gauge.id === id); + setSelectedGaugeId(id); + setAddressData(null); + setAddressError(''); + closeGaugePicker(); + + setValues((current) => ({ + ...current, + ...getGaugeValueUpdates(nextGauge, current), + })); + } + + async function loadAddressPosition(event) { + event.preventDefault(); + + if (!selectedGauge?.address) { + setAddressError('Select a gauge before loading an address.'); + return; + } + + if (!ethers.isAddress(address.trim())) { + setAddressError('Enter a valid Ethereum address.'); + return; + } + + setAddressStatus('loading'); + setAddressError(''); + + try { + const userAddress = ethers.getAddress(address.trim()); + const provider = new ethers.JsonRpcProvider(RPC_URL, 1, { staticNetwork: true }); + const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider); + const vecrvIface = new ethers.Interface(VECRV_ABI); + const gaugeIface = new ethers.Interface(GAUGE_ABI); + + const results = await multicall.aggregate3([ + { + target: VECRV_ADDRESS, + allowFailure: false, + callData: vecrvIface.encodeFunctionData('totalSupply'), + }, + { + target: VECRV_ADDRESS, + allowFailure: false, + callData: vecrvIface.encodeFunctionData('balanceOf', [userAddress]), + }, + { + target: selectedGauge.address, + allowFailure: true, + callData: gaugeIface.encodeFunctionData('balanceOf', [userAddress]), + }, + { + target: selectedGauge.address, + allowFailure: true, + callData: gaugeIface.encodeFunctionData('totalSupply'), + }, + ]); + + const nextTotalVecrv = Number(ethers.formatEther(decodeBigint(vecrvIface, 'totalSupply', results[0]) || 0n)); + const nextUserVecrv = Number(ethers.formatEther(decodeBigint(vecrvIface, 'balanceOf', results[1]) || 0n)); + const gaugeBalance = Number(ethers.formatEther(decodeBigint(gaugeIface, 'balanceOf', results[2]) || 0n)); + const gaugeSupply = Number(ethers.formatEther(decodeBigint(gaugeIface, 'totalSupply', results[3]) || 0n)); + const depositUsd = selectedGauge.lpTokenPrice > 0 ? gaugeBalance * selectedGauge.lpTokenPrice : 0; + const gaugeTvl = selectedGauge.lpTokenPrice > 0 && gaugeSupply > 0 + ? gaugeSupply * selectedGauge.lpTokenPrice + : selectedGauge.stakedTvl; + + setGauges((currentGauges) => currentGauges.map((gauge) => ( + gauge.id === selectedGauge.id + ? { ...gauge, gaugeSupply, stakedTvl: gaugeTvl } + : gauge + ))); + setMode('vecrv'); + setPositionMode('current'); + setValues((current) => ({ + ...current, + userVecrv: String(nextUserVecrv), + totalVecrv: nextTotalVecrv ? String(nextTotalVecrv) : current.totalVecrv, + depositLpTokens: String(gaugeBalance), + depositValue: selectedGauge.lpTokenPrice > 0 ? String(depositUsd.toFixed(2)) : current.depositValue, + poolValue: String(Math.round(gaugeTvl || 0)), + })); + setAddressData({ + address: userAddress, + vecrv: nextUserVecrv, + gaugeBalance, + gaugeSupply, + depositUsd, + gaugeTvl, + }); + setAddressStatus('ready'); + } catch (error) { + setAddressStatus('error'); + setAddressError('Could not load this address from the public Ethereum RPC. You can still enter values manually.'); + } + } + + return ( +
+ {notice ?

{notice}

: null} + +
+
+
+
+ 1 +
+

Select a pool gauge

+

Pick the gauge where your LP tokens are or will be staked.

+
+ {status === 'loading' ? 'Loading gauges' : `${gauges.length || 'Manual'} gauges`} +
+ +
+ Selected pool gauge + + + {isGaugePickerOpen ? ( + <> +
closeGaugePicker()} /> + + + ) : null} +
+ +
+ Gauge contract details +
+
+ Gauge address + {selectedGauge?.address || 'No gauge selected'} +
+
+ Staked in gauge + {formatUsd(selectedGauge?.stakedTvl || 0)} +
+
+ Gauge supply + {formatAmount(selectedGauge?.gaugeSupply || 0)} +
+
+ LP token price + {formatUsd(selectedGauge?.lpTokenPrice || 0, 4)} +
+
+
+
+ +
+
+ 2 +
+

Enter or load your position

+

Paste an address to auto-fill a current position, or enter your LP token amount manually.

+
+
+ +
+ +
+ setAddress(event.target.value)} + placeholder="0x..." + autoComplete="off" + spellCheck="false" + data-lpignore="true" + data-1p-ignore + /> + +
+

+ {addressError || (addressData + ? `Loaded ${shortAddress(addressData.address)}: ${formatAmount(addressData.vecrv)} veCRV and ${formatUsd(addressData.depositUsd)} in this gauge.` + : 'Optional. Uses one multicall to load veCRV, staked LP balance, gauge supply, and total veCRV.')} +

+
+ + {selectedGauge?.lpTokenPrice > 0 ? ( +
+ + { + const nextLpTokens = event.target.value; + updateValue('depositLpTokens', nextLpTokens); + updateValue('depositValue', String(toNumber(nextLpTokens) * selectedGauge.lpTokenPrice)); + }} + /> +

+ Estimated at {formatUsd(depositValue, 2)} using an LP token price of {formatUsd(selectedGauge.lpTokenPrice, 4)}. +

+
+ ) : ( +
+ + updateValue('depositValue', event.target.value)} + /> +

+ LP token price is unavailable for this gauge, so the calculator needs a USD estimate. +

+
+ )} + +
+ Advanced boost inputs +
+
+
+ + updateValue('poolValue', event.target.value)} + /> +

Current USD value staked in this gauge, not total pool liquidity.

+
+
+ + updateValue('totalVecrv', event.target.value)} + /> +

System-wide veCRV denominator.

+
+
+
+ Position type +
+ + +
+

+ Future gauge share uses {positionMode === 'new' ? 'current staked TVL plus your deposit.' : 'current staked TVL as already including your deposit.'} +

+
+
+
+
+ +
+
+ 3 +
+

Enter veCRV or CRV lock details

+

Use current veCRV if you already locked CRV, or estimate veCRV from a planned CRV lock.

+
+
+ +
+ Input mode +
+ + +
+
+ + {mode === 'vecrv' ? ( +
+ + updateValue('userVecrv', event.target.value)} + /> +

Paste your current veCRV balance, or load it from an address above.

+
+ ) : ( +
+
+ + updateValue('crvAmount', event.target.value)} + /> +

{crvPrice ? `${formatUsd(crvInputCost, 2)} at the live CRV price.` : 'CRV price unavailable.'}

+
+
+ + updateValue('lockYears', event.target.value)} + /> +

Years, capped at 4 for max veCRV.

+
+
+ )} + +
+
+ Future veCRV + {formatAmount(userVecrv)} + + {mode === 'crv' + ? `${formatAmount(toNumber(values.crvAmount))} CRV locked for ${lockYears.toFixed(2)} years` + : 'Current veCRV used for this estimate'} + +
+
+ Future veCRV share + {formatPercent(vecrvShare, 5)} + {formatAmount(userVecrv)} / {formatAmount(futureTotalVecrv)} veCRV +
+
+ Holder leaderboard + + {leaderboardStatus === 'loading' + ? 'Loading' + : displayedLeaderboardRank + ? `${isOutsideFetchedLeaderboard ? '>' : '#'}${formatAmount(displayedLeaderboardRank, 0)}` + : 'Unavailable'} + + + {leaderboardStatus === 'ready' + ? (leaderboardMatch + ? 'Matched loaded address' + : isOutsideFetchedLeaderboard + ? `Outside top ${formatAmount(leaderboard.length, 0)} fetched holders` + : 'Estimated by veCRV balance') + : 'Leaderboard source unavailable'} + +
+
+
+
+ + +
+
+ ); +} diff --git a/src/components/BoostCalculator/styles.module.css b/src/components/BoostCalculator/styles.module.css new file mode 100644 index 00000000..00f8fac0 --- /dev/null +++ b/src/components/BoostCalculator/styles.module.css @@ -0,0 +1,802 @@ +.calculator { + --boost-surface: var(--ifm-background-surface-color); + --boost-surface-muted: var(--ifm-color-emphasis-100); + --boost-border: var(--ifm-color-emphasis-200); + --boost-border-strong: var(--ifm-color-emphasis-300); + --boost-text-muted: var(--ifm-color-emphasis-700); + + margin: 1.5rem 0 2rem; + padding: 1rem; + border: 1px solid var(--boost-border); + border-radius: 8px; + background: var(--boost-surface); + font-variant-numeric: tabular-nums; +} + +.badge { + flex: 0 0 auto; + min-height: 2rem; + padding: 0.35rem 0.65rem; + border: 1px solid var(--boost-border-strong); + border-radius: 999px; + color: var(--boost-text-muted); + font-size: 0.78rem; + font-weight: 700; + line-height: 1.2; +} + +.notice, +.errorText { + margin: 0.5rem 0 1rem; + padding: 0.75rem; + border-radius: 6px; + background: var(--ifm-color-warning-contrast-background); + color: var(--ifm-color-warning-contrast-foreground); +} + +.errorText { + margin: 0.4rem 0 0; + background: var(--ifm-color-danger-contrast-background); + color: var(--ifm-color-danger-contrast-foreground); + font-size: 0.84rem; +} + +.guidedLayout { + display: grid; + gap: 1rem; +} + +.steps { + display: grid; + gap: 0.85rem; +} + +.step, +.resultPanel { + min-width: 0; + padding: 0.9rem; + border: 1px solid var(--boost-border); + border-radius: 8px; + background: var(--ifm-background-color); +} + +.resultPanel { + display: grid; + gap: 0.85rem; +} + +.stepHeader { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.stepHeader > div { + min-width: 0; + flex: 1 1 auto; +} + +.stepHeader h4 { + margin: 0; + font-size: 1rem; + line-height: 1.3; +} + +.stepHeader p { + margin: 0.2rem 0 0; + color: var(--boost-text-muted); + font-size: 0.9rem; + line-height: 1.45; +} + +.stepNumber { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 1.65rem; + height: 1.65rem; + border: 1px solid var(--boost-border-strong); + border-radius: 999px; + color: var(--ifm-color-primary); + font-size: 0.82rem; + font-weight: 800; + line-height: 1; +} + +.twoColumn { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.field, +.fieldWide { + min-width: 0; +} + +.fieldWide { + grid-column: 1 / -1; +} + +.step .fieldWide + .fieldWide, +.step .fieldWide + .twoColumn, +.step form + .fieldWide, +.step .fieldWide + .checkboxRow { + margin-top: 0.85rem; +} + +.label { + display: block; + margin-bottom: 0.35rem; + color: var(--ifm-color-emphasis-800); + font-size: 0.9rem; + font-weight: 700; +} + +.input { + width: 100%; + min-height: 44px; + padding: 0.6rem 0.7rem; + border: 1px solid var(--boost-border-strong); + border-radius: 6px; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font: inherit; + font-size: 16px; + touch-action: manipulation; +} + +.input:focus { + border-color: var(--ifm-color-emphasis-600); + outline: 2px solid var(--ifm-color-emphasis-400); + outline-offset: 1px; +} + +.input:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.fieldHint { + margin: 0.4rem 0 0; + color: var(--boost-text-muted); + font-size: 0.84rem; + line-height: 1.4; +} + +.selectorButton { + display: grid; + grid-template-columns: minmax(10rem, 0.9fr) minmax(0, 1.2fr) auto; + gap: 0.85rem; + align-items: start; + width: 100%; + min-height: 74px; + padding: 0.7rem 0.8rem; + border: 1px solid var(--boost-border-strong); + border-radius: 6px; + background: var(--boost-surface); + color: var(--ifm-font-color-base); + cursor: pointer; + font: inherit; + text-align: left; + touch-action: manipulation; +} + +.selectorMain, +.selectorMetrics { + min-width: 0; +} + +.selectorButton strong, +.selectorButton small, +.selectorMetrics span { + display: block; + overflow-wrap: anywhere; +} + +.selectorButton small { + margin-top: 0.1rem; + color: var(--boost-text-muted); + font-size: 0.82rem; +} + +.selectorButton > span:last-child { + align-self: center; + color: var(--ifm-color-primary); + font-size: 0.88rem; + font-weight: 800; +} + +.selectorMetrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; +} + +.selectorMetrics strong { + margin-top: 0.1rem; + font-size: 0.88rem; +} + +.pickerBackdrop { + position: fixed; + z-index: 1000; + inset: 0; + background: rgb(0 0 0 / 38%); +} + +.gaugePicker { + position: fixed; + z-index: 1001; + top: 50%; + left: 50%; + display: flex; + flex-direction: column; + width: min(58rem, calc(100vw - 2rem)); + max-height: 66vh; + padding: 0.9rem; + border: 1px solid var(--boost-border); + border-radius: 8px; + background: var(--ifm-background-color); + box-shadow: 0 18px 48px rgb(0 0 0 / 24%); + transform: translate(-50%, -50%); +} + +.pickerHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.85rem; +} + +.pickerHeader h5 { + margin: 0; + color: var(--ifm-color-emphasis-900); + font-size: 1.05rem; + line-height: 1.3; +} + +.pickerHeader p { + margin: 0.2rem 0 0; + color: var(--boost-text-muted); + font-size: 0.9rem; + line-height: 1.4; +} + +.pickerControls { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.75rem; + margin-top: 0.75rem; +} + +.mobileSortControl { + display: none; +} + +.gaugePicker .checkboxRow { + margin-top: 0.65rem; +} + +.gaugeTableHeader { + display: grid; + grid-template-columns: minmax(12rem, 1.25fr) minmax(5rem, 0.45fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 0.75rem; + margin-top: 0.75rem; + padding: 0 0.75rem; + color: var(--boost-text-muted); + font-size: 0.76rem; + font-weight: 700; +} + +.gaugeTableHeader button { + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +.gaugeTableHeader button[aria-pressed='true'] { + color: var(--ifm-color-primary); +} + +.gaugeTableHeader button:focus { + outline: 2px solid var(--ifm-color-emphasis-400); + outline-offset: 2px; +} + +.gaugeList { + display: grid; + flex: 1 1 auto; + gap: 0.45rem; + min-height: 10rem; + margin-top: 0.35rem; + padding-right: 0.25rem; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.gaugeOption, +.gaugeOptionActive { + display: grid; + grid-template-columns: minmax(12rem, 1.25fr) minmax(5rem, 0.45fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 0.75rem; + align-items: start; + width: 100%; + min-height: 72px; + padding: 0.65rem 0.75rem; + border: 1px solid var(--boost-border); + border-radius: 6px; + background: var(--boost-surface); + color: var(--ifm-font-color-base); + cursor: pointer; + font: inherit; + text-align: left; + touch-action: manipulation; +} + +.gaugeOption strong, +.gaugeOptionActive strong, +.resultStats strong, +.nextStep strong { + display: block; + color: var(--ifm-color-emphasis-900); + line-height: 1.3; +} + +.gaugeOption small, +.gaugeOptionActive small { + display: block; + overflow-wrap: anywhere; + color: var(--boost-text-muted); + font-size: 0.78rem; + line-height: 1.35; +} + +.gaugeMain { + min-width: 0; +} + +.gaugeMeta { + display: contents; + min-width: 0; +} + +.gaugeMeta span { + min-width: 0; +} + +.gaugeMeta strong { + overflow-wrap: anywhere; + font-size: 0.82rem; +} + +.gaugeMeta small { + display: none; +} + +.gaugeOptionActive { + border-color: var(--ifm-color-primary); + background: color-mix(in srgb, var(--ifm-color-primary) 9%, var(--boost-surface)); +} + +.selectorButton:focus, +.gaugeOption:focus, +.gaugeOptionActive:focus, +.advancedDetails summary:focus { + outline: 2px solid var(--ifm-color-emphasis-400); + outline-offset: 1px; +} + +.checkboxRow { + display: flex; + align-items: center; + gap: 0.55rem; + min-height: 44px; + color: var(--boost-text-muted); + cursor: pointer; + font-size: 0.88rem; +} + +.checkboxRow input { + flex: 0 0 auto; +} + +.resultStats, +.inlineMetrics { + display: grid; + gap: 0.65rem; +} + +.resultStats span, +.inlineMetrics span { + display: block; + color: var(--boost-text-muted); + font-size: 0.76rem; + font-weight: 700; +} + +.inlineMetrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 0.85rem; +} + +.inlineMetrics > div { + min-width: 0; + padding: 0.75rem; + border: 1px solid var(--boost-border); + border-radius: 6px; + background: var(--boost-surface-muted); +} + +.inlineMetrics strong { + display: block; + overflow-wrap: anywhere; + margin-top: 0.2rem; + color: var(--ifm-color-emphasis-900); + font-size: 1rem; + line-height: 1.3; +} + +.inlineMetrics small { + display: block; + overflow-wrap: anywhere; + margin-top: 0.2rem; + color: var(--boost-text-muted); + font-size: 0.78rem; + line-height: 1.35; +} + +.addressRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.6rem; +} + +.secondaryButton, +.closeButton { + min-height: 44px; + padding: 0.6rem 0.9rem; + border: 1px solid var(--boost-border-strong); + border-radius: 6px; + background: var(--boost-surface-muted); + color: var(--ifm-font-color-base); + cursor: pointer; + font: inherit; + font-size: 0.94rem; + font-weight: 700; + touch-action: manipulation; +} + +.secondaryButton:focus, +.closeButton:focus { + outline: 2px solid var(--ifm-color-emphasis-400); + outline-offset: 1px; +} + +.secondaryButton:active, +.closeButton:active, +.selectorButton:active, +.gaugeOption:active, +.gaugeOptionActive:active, +.segment:active, +.segmentActive:active { + transform: scale(0.98); +} + +.secondaryButton:disabled, +.closeButton:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.detailsStrip { + display: grid; + grid-column: 1 / -1; + gap: 0; + padding: 0 0 0.35rem; +} + +.detailItem { + display: grid; + grid-template-columns: minmax(8rem, 0.42fr) minmax(0, 1fr); + gap: 1rem; + align-items: baseline; + min-width: 0; + padding: 0.55rem 0; + border-bottom: 1px solid var(--boost-border); +} + +.detailItem:first-child { + padding-top: 0; +} + +.detailItem:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.detailItem span, +.metricLabel, +.heroLabel { + display: block; + color: var(--boost-text-muted); + font-size: 0.78rem; + font-weight: 700; +} + +.detailItem strong { + display: block; + overflow-wrap: anywhere; + text-align: right; + color: var(--ifm-color-emphasis-900); + font-size: 0.92rem; + line-height: 1.35; +} + +.segmentedControl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-height: 44px; + padding: 0.2rem; + border: 1px solid var(--boost-border-strong); + border-radius: 7px; + background: var(--boost-surface-muted); +} + +.segment, +.segmentActive { + min-height: 36px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--boost-text-muted); + cursor: pointer; + font: inherit; + font-size: 0.9rem; + font-weight: 700; + touch-action: manipulation; +} + +.segmentActive { + background: var(--boost-surface); + color: var(--ifm-color-primary); + box-shadow: 0 0 0 1px var(--boost-border); +} + +.heroMetric, +.metric { + min-width: 0; +} + +.heroMetric { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 10.5rem; + padding: 1rem; + border: 1px solid var(--boost-border); + border-radius: 6px; + background: var(--ifm-background-color); +} + +.resultPanel .heroMetric { + min-height: 9rem; +} + +.heroValue { + display: block; + margin: 0.15rem 0; + color: var(--ifm-color-primary); + font-size: clamp(2.4rem, 8vw, 4.5rem); + line-height: 1; +} + +.heroMeta, +.metricSubtext { + color: var(--boost-text-muted); + font-size: 0.86rem; + line-height: 1.4; +} + +.supportGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 1rem; +} + +.nextStep { + display: grid; + gap: 0.25rem; + padding: 0.8rem; + border: 1px solid var(--boost-border); + border-radius: 6px; + background: var(--boost-surface-muted); +} + +.nextStep span { + color: var(--boost-text-muted); + font-size: 0.88rem; + line-height: 1.4; +} + +.resultStats { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.calculator .resultPanel .ctaButton { + color: #fff; + text-decoration: none; +} + +.calculator .resultPanel .ctaButton:hover, +.calculator .resultPanel .ctaButton:focus { + color: #fff; + text-decoration: none; +} + +.linkRow { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + color: var(--boost-text-muted); + font-size: 0.86rem; + line-height: 1.4; +} + +.linkRow a { + font-weight: 700; +} + +.advancedDetails { + margin-top: 0.85rem; + border-top: 1px solid var(--boost-border); +} + +.resultPanel .advancedDetails { + margin-top: 0; +} + +.advancedDetails summary { + min-height: 44px; + padding: 0.7rem 0; + cursor: pointer; + color: var(--ifm-color-emphasis-900); + font-weight: 700; + touch-action: manipulation; +} + +.advancedDetails .detailsStrip, +.advancedDetails .supportGrid { + margin-bottom: 0.35rem; +} + +.detailsContent { + padding: 0 0 0.35rem; + color: var(--boost-text-muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.detailsContent p { + margin: 0.65rem 0 0; +} + +.formulaText { + overflow-wrap: anywhere; + color: var(--ifm-color-emphasis-900); + font-weight: 700; +} + +.metric { + padding: 0.55rem 0; + border-top: 1px solid var(--boost-border); +} + +.metricValue { + display: block; + overflow-wrap: anywhere; + margin: 0.25rem 0; + color: var(--ifm-color-emphasis-900); + font-size: 1.15rem; + line-height: 1.25; +} + +[data-theme='dark'] .calculator { + --boost-surface: var(--Layer-2-Fill, var(--ifm-background-surface-color)); + --boost-surface-muted: var(--Layer-1-Fill, var(--ifm-color-emphasis-100)); + --boost-border: var(--Layer-2-Outline, var(--ifm-color-emphasis-200)); + --boost-border-strong: var(--Layer-2-Outline, var(--ifm-color-emphasis-300)); + --boost-text-muted: var(--Text-TextColors-Secondary, var(--ifm-color-emphasis-700)); +} + +@media (hover: hover) and (pointer: fine) { + .secondaryButton:hover, + .selectorButton:hover, + .gaugeOption:hover { + border-color: var(--ifm-color-emphasis-500); + } +} + +@media (max-width: 996px) { + .guidedLayout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .calculator { + padding: 0.85rem; + } + + .gaugePicker { + width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + padding: 0.75rem; + } + + .pickerHeader { + gap: 0.75rem; + } + + .mobileSortControl { + display: block; + } + + .gaugeTableHeader { + display: none; + } + + .badge { + display: inline-block; + margin-top: 0.75rem; + } + + .twoColumn, + .gaugeMeta, + .pickerControls, + .selectorButton, + .selectorMetrics, + .resultStats, + .inlineMetrics, + .supportGrid, + .addressRow { + grid-template-columns: 1fr; + } + + .gaugeOption, + .gaugeOptionActive { + grid-template-columns: 1fr; + } + + .gaugeMeta { + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + } + + .gaugeMeta small { + display: block; + } + + .secondaryButton { + width: 100%; + } + + .detailItem { + grid-template-columns: 1fr; + gap: 0.2rem; + } + + .detailItem strong { + text-align: left; + } +}