diff --git a/.changeset/smart-countries-grow.md b/.changeset/smart-countries-grow.md new file mode 100644 index 000000000..04298e789 --- /dev/null +++ b/.changeset/smart-countries-grow.md @@ -0,0 +1,5 @@ +--- +"frontend": patch +--- + +Fix Protocol Data diff --git a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/ProtocolData.tsx b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/ProtocolData.tsx index 8399b1bf7..2d5d1903d 100644 --- a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/ProtocolData.tsx +++ b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/ProtocolData.tsx @@ -8,58 +8,163 @@ import { Accordion, Button, ButtonStyle } from '@sovryn/ui'; import { BOB_CHAIN_ID, RSK_CHAIN_ID } from '../../../../../config/chains'; import { AmountRenderer } from '../../../../2_molecules/AmountRenderer/AmountRenderer'; -import { NativeTokenAmount } from '../../../../2_molecules/NativeTokenAmount/NativeTokenAmount'; -import { USD } from '../../../../../constants/currencies'; +import { + BITCOIN, + BTC_RENDER_PRECISION, + USD, +} from '../../../../../constants/currencies'; import { translations } from '../../../../../locales/i18n'; import { decimalic } from '../../../../../utils/math'; import { USD_VALUE_PRECISION } from './ProtocolData.constants'; import styles from './ProtocolData.module.css'; +import { pickBtcUsd, safeBucketUsd, sanitizeUsd } from './ProtocolData.utils'; import { useGetBOBVolume } from './hooks/useGetBOBVolume'; import { useGetLockedData } from './hooks/useGetLockedData'; import { useGetRSKVolume } from './hooks/useGetRSKVolume'; +import { useGetTokens } from './hooks/useGetTokens'; const pageTranslations = translations.landingPage.protocolDataSection; export const ProtocolData: FC = () => { - const lockedData = useGetLockedData(RSK_CHAIN_ID); - const rskVolume = useGetRSKVolume(); + // RSK data (TVL parts + 24h volume) + const rskLocked = useGetLockedData(RSK_CHAIN_ID); - const bobLockedData = useGetLockedData(BOB_CHAIN_ID); - const bobVolume = useGetBOBVolume(); + const rskVolumeRaw = useGetRSKVolume(); // 24h USD (string) + + // BOB data (TVL aggregate + 24h volume) + const bobLocked = useGetLockedData(BOB_CHAIN_ID); + + const bobVolumeRaw = useGetBOBVolume(); // 24h USD (string) + + // Tokens for BTC price sourcing + const { value: rskTokens } = useGetTokens(RSK_CHAIN_ID); + const { value: bobTokens } = useGetTokens(BOB_CHAIN_ID); const navigate = useNavigate(); const [open, toggle] = useReducer(v => !v, false); - const handleClick = useCallback(() => navigate('/stats'), [navigate]); - const rskTVL = useMemo( + // Prefer RSK RBTC/WRBTC for stability; fallback to BOB BTC wrappers. + const rskBtcUsd = useMemo( + () => pickBtcUsd(rskTokens, ['RBTC', 'WRBTC', 'BTC']) ?? 0, + [rskTokens], + ); + const bobBtcUsd = useMemo( () => - decimalic(lockedData.tvlAmm?.totalUsd || '0') - .add(lockedData.tvlLending?.totalUsd || '0') - .add(lockedData.tvlMynt?.totalUsd || '0') - .add(lockedData.tvlProtocol?.totalUsd || '0') - .add(lockedData.tvlStaking?.totalUsd || '0') - .add(lockedData.tvlSubprotocols?.totalUsd || '0') - .add(lockedData.tvlZero?.totalUsd || '0') - .toString(), - [ - lockedData.tvlAmm?.totalUsd, - lockedData.tvlLending?.totalUsd, - lockedData.tvlMynt?.totalUsd, - lockedData.tvlProtocol?.totalUsd, - lockedData.tvlStaking?.totalUsd, - lockedData.tvlSubprotocols?.totalUsd, - lockedData.tvlZero?.totalUsd, - ], + pickBtcUsd(bobTokens, [ + 'WBTC', + 'tBTC', + 'UniBTC', + 'XSOLVBTC', + 'SolvBTC', + 'BTC', + ]) ?? 0, + [bobTokens], + ); + + // Use ONE global BTC/USD for totals (avoid double-scaling differences). + const globalBtcUsd = useMemo( + () => rskBtcUsd || bobBtcUsd || 0, + [rskBtcUsd, bobBtcUsd], + ); + + const rskTvlUsd = useMemo(() => { + if (!rskLocked) { + return '0'; + } + + // Aggregate from buckets, applying numeric caps per bucket + const fromBuckets = decimalic(0) + .add(safeBucketUsd(rskLocked.tvlAmm)) + .add(safeBucketUsd(rskLocked.tvlLending)) + .add(safeBucketUsd(rskLocked.tvlMynt)) + .add(safeBucketUsd(rskLocked.tvlProtocol)) + .add(safeBucketUsd(rskLocked.tvlStaking)) + .add(safeBucketUsd(rskLocked.tvlSubprotocols)) + .add(safeBucketUsd(rskLocked.tvlZero)); + + //sanity-check against raw total_usd/totalUsd from backend + const rawTotal = Number( + (rskLocked as any)?.total_usd ?? (rskLocked as any)?.totalUsd ?? 0, + ); + const safeRawTotal = + !isFinite(rawTotal) || rawTotal < 0 || rawTotal > 1e9 ? 0 : rawTotal; + + if (safeRawTotal > 0) { + const diff = Math.abs(fromBuckets.toNumber() - safeRawTotal); + const relDiff = diff / safeRawTotal; + + // Log if discrepancy > ~3% (indexer vs graph-wrapper) + if (relDiff > 0.03) { + // eslint-disable-next-line no-console + console.warn('[ProtocolData] RSK TVL mismatch', { + buckets: fromBuckets.toString(), + total_usd: safeRawTotal.toString(), + relDiff, + }); + } + } + + return fromBuckets.toString(); + }, [rskLocked]); + + const bobTvlUsd = useMemo(() => { + if (!bobLocked) { + return '0'; + } + + const raw = + (bobLocked as any)?.total_usd ?? (bobLocked as any)?.totalUsd ?? 0; + + return sanitizeUsd(raw, 1e9); + }, [bobLocked]); + + const rskTvlBtc = useMemo( + () => (rskBtcUsd ? decimalic(rskTvlUsd).div(rskBtcUsd).toString() : '0'), + [rskTvlUsd, rskBtcUsd], + ); + const bobTvlBtc = useMemo( + () => (bobBtcUsd ? decimalic(bobTvlUsd).div(bobBtcUsd).toString() : '0'), + [bobTvlUsd, bobBtcUsd], + ); + + const rskVolumeUsd = useMemo( + () => sanitizeUsd(rskVolumeRaw, 1e12), + [rskVolumeRaw], + ); + const bobVolumeUsd = useMemo( + () => sanitizeUsd(bobVolumeRaw, 1e12), + [bobVolumeRaw], + ); + + const rskVolumeBtc = useMemo( + () => (rskBtcUsd ? decimalic(rskVolumeUsd).div(rskBtcUsd).toString() : '0'), + [rskVolumeUsd, rskBtcUsd], + ); + const bobVolumeBtc = useMemo( + () => (bobBtcUsd ? decimalic(bobVolumeUsd).div(bobBtcUsd).toString() : '0'), + [bobVolumeUsd, bobBtcUsd], + ); + + const totalTvlUsd = useMemo( + () => decimalic(rskTvlUsd).add(bobTvlUsd).toString(), + [rskTvlUsd, bobTvlUsd], + ); + const totalVolUsd = useMemo( + () => decimalic(rskVolumeUsd).add(bobVolumeUsd).toString(), + [rskVolumeUsd, bobVolumeUsd], ); - const total = useMemo(() => { - return { - lockedData: - Number(rskTVL || '0') + Number(bobLockedData.total_usd || '0'), - volumeData: Number(rskVolume || '0') + Number(bobVolume || '0'), - }; - }, [bobLockedData.total_usd, bobVolume, rskTVL, rskVolume]); + const totalTvlBtc = useMemo( + () => + globalBtcUsd ? decimalic(totalTvlUsd).div(globalBtcUsd).toString() : '0', + [totalTvlUsd, globalBtcUsd], + ); + const totalVolBtc = useMemo( + () => + globalBtcUsd ? decimalic(totalVolUsd).div(globalBtcUsd).toString() : '0', + [totalVolUsd, globalBtcUsd], + ); return (
@@ -79,41 +184,50 @@ export const ProtocolData: FC = () => { + {/* TOTAL TVL (All Networks) — BTC + USD (one BTC/USD) */}
{t(pageTranslations.totalValueLockedAllNetworks)}
+ + {/* BTC total */}
-
+ {/* USD total */}
+ {/* TOTAL 24H VOLUME (All Networks) — BTC + USD (one BTC/USD) */}
{t(pageTranslations.volumeAllNetworks)}
-
- +
+ {/* 24h volume in USD */}
@@ -123,18 +237,26 @@ export const ProtocolData: FC = () => { } children={
+ {/* RSK ROW */}
{t(pageTranslations.tvlRskNetwork)}
+ + {/* TVL (RSK) in BTC */}
- +
+ {/* TVL (RSK) in USD */}
@@ -145,16 +267,20 @@ export const ProtocolData: FC = () => {
{t(pageTranslations.volumeRskNetwork)}
+ + {/* 24h Volume (RSK) in BTC */}
-
+ {/* 24h Volume (RSK) in USD */}
@@ -162,20 +288,26 @@ export const ProtocolData: FC = () => {
+ {/* BOB ROW */}
{t(pageTranslations.tvlBobNetwork)}
-
- +
+ + {/* TVL (BOB) in USD */}
@@ -186,16 +318,20 @@ export const ProtocolData: FC = () => {
{t(pageTranslations.volumeBobNetwork)}
+ + {/* 24h Volume (BOB) in BTC */}
-
+ {/* 24h Volume (BOB) in USD */}
diff --git a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/ProtocolData.utils.tsx b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/ProtocolData.utils.tsx new file mode 100644 index 000000000..c80fcd15b --- /dev/null +++ b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/ProtocolData.utils.tsx @@ -0,0 +1,37 @@ +import { decimalic } from '../../../../../utils/math'; + +/** Reject non-finite or absurd USD values; return "0" if invalid. */ +export const sanitizeUsd = (raw: unknown, cap = 1e12) => { + const n = Number(raw ?? 0); + if (!isFinite(n) || n < 0 || n > cap) return '0'; + return String(n); +}; + +/** Numeric guard for TVL buckets (RSK/BOB). Values > cap are treated as 0. */ +export const sanitizeTvlBucket = (raw: unknown, cap = 1e9): string => { + const n = Number(raw ?? 0); + if (!isFinite(n) || n < 0 || n > cap) return '0'; + return String(n); +}; + +/** Safely convert TVL bucket totalUsd -> decimalic with cap. */ +export const safeBucketUsd = ( + bucket?: { totalUsd?: string | number }, + cap = 1e9, +) => decimalic(sanitizeTvlBucket(bucket?.totalUsd, cap)); + +/** Pick a reasonable BTC/USD from token lists; validate rough range. */ +export const pickBtcUsd = ( + tokens: Array<{ symbol?: string; usdPrice?: string | number }> = [], + symbols: string[], +): number | undefined => { + for (const s of symbols) { + const tok = tokens.find( + t => + t?.symbol?.toUpperCase() === s.toUpperCase() && Number(t.usdPrice) > 0, + ); + const px = Number(tok?.usdPrice); + if (isFinite(px) && px >= 1_000 && px <= 200_000) return px; // wide but sane + } + return undefined; +}; diff --git a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetBOBVolume.ts b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetBOBVolume.ts index e9ffa967b..8db0e3d6d 100644 --- a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetBOBVolume.ts +++ b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetBOBVolume.ts @@ -15,21 +15,32 @@ import { decimalic } from '../../../../../../utils/math'; import { useGetTokens } from './useGetTokens'; const indexer = getIndexerUrl(); -console.log(indexer + 'sdex/volume'); + +// hard overrides if token list is wrong +const DECIMALS_OVERRIDE: Record = { + // SOV + '0xba20a5e63eeefffa6fd365e7e540628f8fc61474': 18, + // WBTC + '0x0555e30da8f98308edb960aa94c0db47230d2b9c': 8, + // POWA + '0xd0c2f08a873186db5cfb7b767db62bef9e495bff': 18, +}; + +// Treat ultra-small prices as broken (e.g. POWA mispriced at ~1e-7 USD), +// and cap at a generous upper bound for safety. +const isSanePrice = (p: number) => isFinite(p) && p >= 1e-5 && p < 200_000; export const useGetBOBVolume = () => { const { currentChainId } = useChainStore(); const { value: tokens } = useGetTokens(BOB_CHAIN_ID); + const { value: volumes } = useCacheCall( `sdex/volume`, BOB_CHAIN_ID, async () => { const { data } = await axios.get(indexer + 'sdex/volume', { - params: { - chainId: Number(BOB_CHAIN_ID), - }, + params: { chainId: Number(BOB_CHAIN_ID) }, }); - return (data?.data || []) as { token: string; volume: string }[]; }, [currentChainId], @@ -37,24 +48,42 @@ export const useGetBOBVolume = () => { ); return useMemo(() => { - if (!tokens.length || !volumes.length) { - return '0'; - } + if (!tokens.length || !volumes.length) return '0'; let sum = decimalic(0); - volumes.forEach(volumeData => { - const token = tokens.find(t => - areAddressesEqual(t.address, volumeData.token), - ); + for (const row of volumes) { + const token = tokens.find(t => areAddressesEqual(t.address, row.token)); + if (!token) continue; + + const addr = token.address.toLowerCase(); + const decimals = DECIMALS_OVERRIDE[addr] ?? Number(token.decimals ?? 18); + const price = Number(token.usdPrice); - if (token) { - const volume = decimalic(volumeData.volume).toString(); - sum = sum.add( - decimalic(token.usdPrice).mul(formatUnits(volume, token.decimals)), - ); + // skip insane decimals + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { + continue; } - }); + + // drop broken feeds like POWA ~ 1e-7, NaN, negative, etc. + if (!isSanePrice(price)) continue; + + // raw volume is in base units; normalize with decimals + let units = '0'; + try { + units = formatUnits(row.volume, decimals); + } catch { + continue; + } + + const usd = decimalic(price).mul(units); + + // drop absurd contributions (protect against a single bad token dominating) + const usdNum = Number(usd); + if (!isFinite(usdNum) || usdNum > 1e12) continue; + + sum = sum.add(usd); + } return sum.toString(); }, [tokens, volumes]); diff --git a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetLockedData.ts b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetLockedData.ts index 0d818edf7..a6f7b120d 100644 --- a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetLockedData.ts +++ b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetLockedData.ts @@ -4,9 +4,12 @@ import axios, { Canceler } from 'axios'; import { ChainId } from '@sovryn/ethers-provider'; +import { BOB_CHAIN_ID } from '../../../../../../config/chains'; + import { DATA_REFRESH_INTERVAL } from '../../../../../../constants/general'; import { useChainStore } from '../../../../../../hooks/useChainStore'; import { useInterval } from '../../../../../../hooks/useInterval'; +import { decimalic } from '../../../../../../utils/math'; import { DEFAULT_LOCKED_DATA, LOCKED_DATA_URL, @@ -33,9 +36,80 @@ export const useGetLockedData = (chainId?: ChainId) => { cancelToken, }) .then(result => { - if (result.data.data) { - setLockedData(result.data.data); + const data = result?.data?.data; + if (!data) return; + + // BOB sanitizer: recompute total from parts; drop insane entries + if (Number(chainId || currentChainId) === Number(BOB_CHAIN_ID)) { + // BTC-ish symbols where we validate unit price hard + const CLAMP_BTC_SYMBOLS = new Set([ + 'WBTC', + 'WBTC.OLD', + 'UniBTC', + 'tBTC', + 'XSOLVBTC', + 'SolvBTC', + 'BTC', + 'RBTC', + 'WRBTC', + ]); + + const MAX_UNIT_PRICE_USD = 200_000; // very generous BTC cap + const MAX_ASSET_USD = 1e9; // per-asset USD clamp (1B) + + let safeTotal = decimalic(0); + + // Only sum per-asset; ignore bucket totalUsd fields + const buckets = [ + 'tvlSdex', + 'tvlLending', + 'tvlProtocol', + 'tvlStaking', + 'tvlSubprotocols', + 'tvlAmm', + 'tvlZero', + 'tvlMynt', + ]; + + for (const key of buckets) { + const bucket = (data as any)[key]; + if (!bucket || typeof bucket !== 'object') continue; + + for (const [assetKey, assetVal] of Object.entries(bucket)) { + // skip bucket-level totals like "totalUsd" + if (assetKey === 'totalUsd' || assetKey === 'total_usd') continue; + + const v: any = assetVal; + if (!v || typeof v !== 'object') continue; + + const sym = String(v.assetName || assetKey || '').toUpperCase(); + const balance = Number(v.balance ?? v.amount ?? 0); + let usd = Number(v.balanceUsd ?? v.totalUsd ?? v.usd ?? 0); + + if (!Number.isFinite(usd) || usd <= 0) continue; + if (usd > MAX_ASSET_USD) continue; + + // For BTC-like, validate unit price + if ( + CLAMP_BTC_SYMBOLS.has(sym) && + Number.isFinite(balance) && + balance > 0 + ) { + const unit = usd / balance; + if (!Number.isFinite(unit) || unit > MAX_UNIT_PRICE_USD) + continue; + } + + safeTotal = safeTotal.add(usd); + } + } + + // Write the sanitized totals back so the UI necessarily picks them up + (data as any).total_usd = safeTotal.toString(); + (data as any).totalUsd = (data as any).total_usd; } + + setLockedData(data); }) .catch(() => {}); }, [chainId, currentChainId]); diff --git a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetRSKVolume.ts b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetRSKVolume.ts index b62aea974..136323873 100644 --- a/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetRSKVolume.ts +++ b/apps/frontend/src/app/5_pages/LandingPage/components/ProtocolData/hooks/useGetRSKVolume.ts @@ -36,11 +36,11 @@ export const useGetRSKVolume = () => { }, cancelToken, }) - .then(result => { + .then(result => setVolumeData({ usd: result.data?.data?.total_volume_usd || 0, - }); - }) + }), + ) .catch(() => {}); }, []); diff --git a/apps/frontend/src/utils/helpers.ts b/apps/frontend/src/utils/helpers.ts index 31f6ab352..c08132e35 100644 --- a/apps/frontend/src/utils/helpers.ts +++ b/apps/frontend/src/utils/helpers.ts @@ -163,7 +163,7 @@ export const composeGas = (priceInGwei: Decimalish, limitInWei: Decimalish) => .div(10 ** 18); export const isMobileDevice = () => { - const config = resolveConfig(tailwindConfig); + const config = resolveConfig(tailwindConfig as any); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const widthToCheck: string = config?.theme?.screens.md; // value will be in format "768px"