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"