Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions src/data-sources/subgraph/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,25 @@ const transformSubgraphMarketToMarket = (
if (!('peg' in token) || token.peg === undefined) {
return undefined;
}
const peg = token.peg as TokenPeg;
if (peg === TokenPeg.USD) {
return 1;

switch (token.peg) {
case TokenPeg.USD:
return 1;
case TokenPeg.ETH:
case TokenPeg.BTC:
return majorPrices[token.peg];
default:
return undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};

const fillMissingPrice = (currentPrice: number, token: ERC20Token | UnknownERC20Token | undefined): number => {
if (currentPrice > 0 || !token) {
return currentPrice;
}
return majorPrices[peg];

const estimatedPrice = getEstimateValue(token);
return estimatedPrice === undefined ? currentPrice : estimatedPrice;
};

const mapToken = (token: Partial<SubgraphToken> | undefined) => ({
Expand Down Expand Up @@ -107,15 +121,8 @@ const transformSubgraphMarketToMarket = (
warnings.push(UNRECOGNIZED_COLLATERAL);
}

if (!hasUSDPrice) {
// no price available, try to estimate
if (knownLoadAsset) {
loanAssetPrice = getEstimateValue(knownLoadAsset) ?? 0;
}
if (knownCollateralAsset) {
collateralAssetPrice = getEstimateValue(knownCollateralAsset) ?? 0;
}
}
loanAssetPrice = fillMissingPrice(loanAssetPrice, knownLoadAsset);
collateralAssetPrice = fillMissingPrice(collateralAssetPrice, knownCollateralAsset);

const supplyAssetsUsd = formatBalance(supplyAssets, loanAsset.decimals) * loanAssetPrice;
const borrowAssetsUsd = formatBalance(borrowAssets, loanAsset.decimals) * loanAssetPrice;
Expand Down
169 changes: 125 additions & 44 deletions src/utils/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '@/hooks/useOracleMetadata';
import { formatSimple } from './balance';
import { SupportedNetworks } from './networks';
import { TokenPeg, supportedTokens } from './tokens';

type VendorInfo = {
coreVendors: PriceFeedVendors[]; // Well-known vendors (Chainlink, Redstone, etc.)
Expand Down Expand Up @@ -303,14 +304,105 @@ type CheckFeedsPathResult = {
hasUnknownFeed?: boolean;
missingPath?: string;
expectedPath?: string;
actualPath?: string;
inferredAssumptions?: string[];
};

// Non-token symbols (and a few canonical aliases) that can appear in oracle paths.
// These anchors only explain unresolved path assumptions; they do not make a
// mismatched oracle path valid. Registered ERC20s should resolve through
// supportedTokens + peg metadata instead.
const PEG_ANCHOR_SYMBOLS: Partial<Record<string, TokenPeg>> = {
usd: TokenPeg.USD,
eth: TokenPeg.ETH,
weth: TokenPeg.ETH,
steth: TokenPeg.ETH,
btc: TokenPeg.BTC,
xrp: TokenPeg.XRP,
hype: TokenPeg.HYPE,
whype: TokenPeg.HYPE,
};

/**
* Normalize asset symbols for comparison
*/
function normalizeSymbol(symbol: string): string {
const normalized = symbol.toLowerCase();
return normalized === 'weth' ? 'eth' : normalized;
if (normalized === 'weth') return 'eth';
if (normalized === 'whype') return 'hype';
return normalized;
}

function getPegAnchor(symbol: string): TokenPeg | null {
const normalized = normalizeSymbol(symbol);
const canonicalAnchor = PEG_ANCHOR_SYMBOLS[normalized];
if (canonicalAnchor !== undefined) {
return canonicalAnchor;
}

const matchingPegs = Array.from(
new Set(
supportedTokens
.filter((supportedToken) => normalizeSymbol(supportedToken.symbol) === normalized)
.map((supportedToken) => supportedToken.peg)
.filter((peg): peg is TokenPeg => peg != null),
),
);

return matchingPegs.length === 1 ? matchingPegs[0] : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Infer a missing hardcoded assumption from the exact unresolved path.
*
* Inputs are the expected market asset symbol and the exact remaining symbol on the
* unresolved oracle path after standard cancellation. If both resolve to the same peg
* anchor, we surface that missing conversion as an assumption.
*/
function inferAssumptionLabel(expectedSymbol: string, actualSymbol: string): string | null {
if (normalizeSymbol(expectedSymbol) === normalizeSymbol(actualSymbol)) {
return null;
}

const expectedPeg = getPegAnchor(expectedSymbol);
const actualPeg = getPegAnchor(actualSymbol);

if (!expectedPeg || !actualPeg || expectedPeg !== actualPeg) {
return null;
}

return `${expectedSymbol} <> ${actualSymbol} peg`;
}

function formatPathMismatchWarning(actualPath: string, expectedPath: string, inferredAssumptions: string[]): string {
if (actualPath === 'EMPTY/EMPTY') {
return 'Oracle path mismatch: no price path found.';
}

const formattedPath = actualPath.toUpperCase();
if (inferredAssumptions.length > 0) {
return `Oracle has hardcoded path: ${formattedPath}. This assumes ${inferredAssumptions.join(' and ')}.`;
}

return `Oracle uses ${formattedPath} instead of ${expectedPath.toUpperCase()}. Depegs or divergence won't be reflected.`;
}

function cancelOutAssets(numeratorAssets: string[], denominatorAssets: string[], areEquivalent: (left: string, right: string) => boolean) {
const remainingDenominatorAssets = [...denominatorAssets];
const remainingNumeratorAssets: string[] = [];

for (const numeratorAsset of numeratorAssets) {
const denominatorIndex = remainingDenominatorAssets.findIndex((denominatorAsset) => areEquivalent(numeratorAsset, denominatorAsset));

if (denominatorIndex >= 0) {
remainingDenominatorAssets.splice(denominatorIndex, 1);
continue;
}

remainingNumeratorAssets.push(numeratorAsset);
}

return { remainingNumeratorAssets, remainingDenominatorAssets };
}

type FeedPathEntry = {
Expand All @@ -330,80 +422,69 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string,
return { isValid: false, hasUnknownFeed: true };
}

const numeratorCounts = new Map<string, number>();
const denominatorCounts = new Map<string, number>();
const numeratorAssets: string[] = [];
const denominatorAssets: string[] = [];

const incrementCount = (map: Map<string, number>, asset: string) => {
const pushAsset = (assets: string[], asset: string) => {
if (asset !== 'EMPTY') {
const normalizedAsset = normalizeSymbol(asset);
map.set(normalizedAsset, (map.get(normalizedAsset) ?? 0) + 1);
assets.push(asset);
}
};

for (const { path, type, hasData } of feedPaths) {
if (!hasData) continue;

if (type === 'base1' || type === 'base2' || type === 'baseVault') {
incrementCount(numeratorCounts, path.base);
incrementCount(denominatorCounts, path.quote);
pushAsset(numeratorAssets, path.base);
pushAsset(denominatorAssets, path.quote);
} else {
incrementCount(denominatorCounts, path.base);
incrementCount(numeratorCounts, path.quote);
pushAsset(denominatorAssets, path.base);
pushAsset(numeratorAssets, path.quote);
}
}

const cancelOut = (num: Map<string, number>, den: Map<string, number>) => {
const assets = new Set([...num.keys(), ...den.keys()]);

for (const asset of assets) {
const numCount = num.get(asset) ?? 0;
const denCount = den.get(asset) ?? 0;
const minCount = Math.min(numCount, denCount);

if (minCount > 0) {
num.set(asset, numCount - minCount);
den.set(asset, denCount - minCount);

if (num.get(asset) === 0) num.delete(asset);
if (den.get(asset) === 0) den.delete(asset);
}
}
};

cancelOut(numeratorCounts, denominatorCounts);

const remainingNumeratorAssets = Array.from(numeratorCounts.keys()).filter((asset) => (numeratorCounts.get(asset) ?? 0) > 0);
const remainingDenominatorAssets = Array.from(denominatorCounts.keys()).filter((asset) => (denominatorCounts.get(asset) ?? 0) > 0);
const exactCancellation = cancelOutAssets(
numeratorAssets,
denominatorAssets,
(left, right) => normalizeSymbol(left) === normalizeSymbol(right),
);
const remainingNumeratorAssets = exactCancellation.remainingNumeratorAssets;
const remainingDenominatorAssets = exactCancellation.remainingDenominatorAssets;

const normalizedCollateralSymbol = normalizeSymbol(collateralSymbol);
const normalizedLoanSymbol = normalizeSymbol(loanSymbol);

const expectedPath = `${normalizedCollateralSymbol}/${normalizedLoanSymbol}`;
const expectedDisplayPath = `${collateralSymbol}/${loanSymbol}`;

const isValid =
remainingNumeratorAssets.length === 1 &&
remainingDenominatorAssets.length === 1 &&
remainingNumeratorAssets[0] === normalizedCollateralSymbol &&
remainingDenominatorAssets[0] === normalizedLoanSymbol &&
(numeratorCounts.get(normalizedCollateralSymbol) ?? 0) === 1 &&
(denominatorCounts.get(normalizedLoanSymbol) ?? 0) === 1;
normalizeSymbol(remainingNumeratorAssets[0]) === normalizedCollateralSymbol &&
normalizeSymbol(remainingDenominatorAssets[0]) === normalizedLoanSymbol;

if (isValid) {
return { isValid: true };
}

let missingPath = '';
if (remainingNumeratorAssets.length === 0 && remainingDenominatorAssets.length === 0) {
missingPath = 'All assets canceled out - no price path found';
} else {
const actualPath = `${remainingNumeratorAssets.join('*')}/${remainingDenominatorAssets.join('*')}`;
missingPath = `Oracle uses ${actualPath.toUpperCase()} instead of ${expectedPath.toUpperCase()}. Depegs or divergence won't be reflected`;
}
const actualPath = `${exactCancellation.remainingNumeratorAssets.join('*') || 'EMPTY'}/${exactCancellation.remainingDenominatorAssets.join('*') || 'EMPTY'}`;

const inferredAssumptions = [
exactCancellation.remainingNumeratorAssets.length === 1
? inferAssumptionLabel(collateralSymbol, exactCancellation.remainingNumeratorAssets[0])
: null,
exactCancellation.remainingDenominatorAssets.length === 1
? inferAssumptionLabel(loanSymbol, exactCancellation.remainingDenominatorAssets[0])
: null,
].filter((value): value is string => Boolean(value));

const missingPath = formatPathMismatchWarning(actualPath, expectedDisplayPath, inferredAssumptions);

return {
isValid: false,
missingPath,
expectedPath,
actualPath,
inferredAssumptions,
};
}

Expand Down
11 changes: 9 additions & 2 deletions src/utils/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ export type SingleChainERC20Basic = {
address: string;
};

// a token can be "linked" to a pegged asset, we use this to estimate the USD value for markets if it's not presented.
// A token can be linked to a loose reference asset. USD, ETH, and BTC currently
// have price fallback sources; the broader enum is also used to explain oracle
// path assumptions when scanner feeds use an anchor symbol instead of the token.
export enum TokenPeg {
USD = 'USD',
ETH = 'ETH',
BTC = 'BTC',
XRP = 'XRP',
HYPE = 'HYPE',
Comment on lines +19 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Separate oracle anchors from price-estimation pegs.

ERC20Token.peg is used by USD price fallbacks, but XRP/HYPE are not resolved by the market or token-price paths, so WHYPE/cbXRP still fall to 0/undefined when direct USD pricing is missing. If these are only for oracle warnings, use a separate oracle-anchor field; otherwise add XRP/HYPE price sources.

Also applies to: 786-786, 829-829

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/tokens.ts` around lines 17 - 18, The ERC20Token.peg field is being
used both for USD price fallbacks and for oracle-warning anchors, causing
XRP/HYPE (and WHYPE/cbXRP) to resolve to 0/undefined when market/token-price
paths don't provide USD; introduce a separate field (e.g., oracleAnchor) on the
ERC20Token definition and move XRP and HYPE values to that field for oracle-only
use, then update all code paths that currently read ERC20Token.peg for warnings
to use ERC20Token.oracleAnchor instead; alternatively, if you intend XRP/HYPE to
be used for price fallbacks, add proper price sources for XRP and HYPE to the
market/token-price resolution logic (update token price registry and resolver
functions) so they are resolved instead of falling back to zero. Ensure
references to ERC20Token.peg remain for USD fallbacks and search for uses in
warning/anchor code (e.g., where WHYPE/cbXRP are handled) and replace with the
new oracleAnchor symbol.

}

export type ERC20Token = {
Expand All @@ -27,7 +31,8 @@ export type ERC20Token = {
isFactoryToken?: boolean;
source?: TokenSource;

// this is not a "hard peg", instead only used for market supply / borrow USD value estimation
// Not a hard-peg guarantee. It may backfill market USD values only when a
// supported reference price exists, and it may label oracle path assumptions.
peg?: TokenPeg;
};

Expand Down Expand Up @@ -781,6 +786,7 @@ const supportedTokens = [
address: '0x5555555555555555555555555555555555555555',
},
],
peg: TokenPeg.HYPE,
},
{
symbol: 'UETH',
Expand Down Expand Up @@ -823,6 +829,7 @@ const supportedTokens = [
img: require('../imgs/tokens/cbxrp.png') as string,
decimals: 6,
networks: [{ chain: base, address: '0xcb585250f852C6c6bf90434AB21A00f02833a4af' }],
peg: TokenPeg.XRP,
},
{
symbol: 'cbADA',
Expand Down