From 98406d7b79e85023feece04777145a104d0429d7 Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Sun, 19 Apr 2026 23:56:52 +0800 Subject: [PATCH 1/7] fix: infer hardcoded oracle assumptions in path warnings --- src/utils/oracle.ts | 142 +++++++++++++++++++++++++++++++------------- 1 file changed, 102 insertions(+), 40 deletions(-) diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index a9fb9186..ceb94566 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -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.) @@ -303,14 +304,73 @@ type CheckFeedsPathResult = { hasUnknownFeed?: boolean; missingPath?: string; expectedPath?: string; + actualPath?: string; + inferredAssumptions?: string[]; +}; + +// Symbols in the same UI-resolution group are treated as already resolved for path validation. +// Keep this list extremely small: only exact wrappers / naming continuations we explicitly want +// to suppress warnings for, not general peg-equivalent assets. +const SAME_FAMILY_SYMBOL_GROUPS: Record = { + weth: 'eth', + usds: 'maker-usd', + dai: 'maker-usd', }; /** * Normalize asset symbols for comparison */ function normalizeSymbol(symbol: string): string { - const normalized = symbol.toLowerCase(); - return normalized === 'weth' ? 'eth' : normalized; + return symbol.toLowerCase(); +} + +function normalizeEquivalentSymbol(symbol: string): string { + const normalized = normalizeSymbol(symbol); + return SAME_FAMILY_SYMBOL_GROUPS[normalized] ?? normalized; +} + +function getPegAnchor(symbol: string): TokenPeg | null { + const normalized = normalizeSymbol(symbol); + + if (normalized === 'usd') return TokenPeg.USD; + if (normalized === 'eth' || normalized === 'weth') return TokenPeg.ETH; + if (normalized === 'btc') return TokenPeg.BTC; + + const token = supportedTokens.find((supportedToken) => normalizeSymbol(supportedToken.symbol) === normalized); + return token?.peg ?? null; +} + +function inferAssumptionLabel(expectedSymbol: string, actualSymbol: string): string | null { + if (normalizeEquivalentSymbol(expectedSymbol) === normalizeEquivalentSymbol(actualSymbol)) { + return null; + } + + const expectedPeg = getPegAnchor(expectedSymbol); + const actualPeg = getPegAnchor(actualSymbol); + + if (!expectedPeg || !actualPeg || expectedPeg !== actualPeg) { + return null; + } + + return `${expectedSymbol} <> ${actualSymbol} peg`; +} + +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 = { @@ -330,13 +390,12 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, return { isValid: false, hasUnknownFeed: true }; } - const numeratorCounts = new Map(); - const denominatorCounts = new Map(); + const numeratorAssets: string[] = []; + const denominatorAssets: string[] = []; - const incrementCount = (map: Map, 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); } }; @@ -344,66 +403,69 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, 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, den: Map) => { - 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); + const exactCancellation = cancelOutAssets( + numeratorAssets, + denominatorAssets, + (left, right) => normalizeSymbol(left) === normalizeSymbol(right), + ); + const equivalentCancellation = cancelOutAssets( + numeratorAssets, + denominatorAssets, + (left, right) => normalizeEquivalentSymbol(left) === normalizeEquivalentSymbol(right), + ); - 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 remainingNumeratorAssets = equivalentCancellation.remainingNumeratorAssets; + const remainingDenominatorAssets = equivalentCancellation.remainingDenominatorAssets; const normalizedCollateralSymbol = normalizeSymbol(collateralSymbol); const normalizedLoanSymbol = normalizeSymbol(loanSymbol); - const expectedPath = `${normalizedCollateralSymbol}/${normalizedLoanSymbol}`; const isValid = remainingNumeratorAssets.length === 1 && remainingDenominatorAssets.length === 1 && - remainingNumeratorAssets[0] === normalizedCollateralSymbol && - remainingDenominatorAssets[0] === normalizedLoanSymbol && - (numeratorCounts.get(normalizedCollateralSymbol) ?? 0) === 1 && - (denominatorCounts.get(normalizedLoanSymbol) ?? 0) === 1; + normalizeEquivalentSymbol(remainingNumeratorAssets[0]) === normalizeEquivalentSymbol(collateralSymbol) && + normalizeEquivalentSymbol(remainingDenominatorAssets[0]) === normalizeEquivalentSymbol(loanSymbol); if (isValid) { return { isValid: true }; } + 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)); + let missingPath = ''; - if (remainingNumeratorAssets.length === 0 && remainingDenominatorAssets.length === 0) { + if (exactCancellation.remainingNumeratorAssets.length === 0 && exactCancellation.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`; + missingPath = `Oracle uses ${actualPath.toUpperCase()} instead of ${expectedPath.toUpperCase()}. Depegs or divergence won't be reflected.`; + if (inferredAssumptions.length > 0) { + missingPath += ` Monarch infers hardcoded assumptions: ${inferredAssumptions.join(' and ')}.`; + } } return { isValid: false, missingPath, expectedPath, + actualPath, + inferredAssumptions, }; } From 9bb8d1717d8f85a774fbddde3545258e726873e1 Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Mon, 20 Apr 2026 00:51:10 +0800 Subject: [PATCH 2/7] fix: tighten inferred oracle assumption warnings --- src/utils/oracle.ts | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index ceb94566..2558cf0c 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -336,8 +336,37 @@ function getPegAnchor(symbol: string): TokenPeg | null { if (normalized === 'eth' || normalized === 'weth') return TokenPeg.ETH; if (normalized === 'btc') return TokenPeg.BTC; - const token = supportedTokens.find((supportedToken) => normalizeSymbol(supportedToken.symbol) === normalized); - return token?.peg ?? null; + const matchingPegs = Array.from( + new Set( + supportedTokens + .filter((supportedToken) => normalizeSymbol(supportedToken.symbol) === normalized) + .map((supportedToken) => supportedToken.peg) + .filter((peg): peg is TokenPeg => peg != null), + ), + ); + + if (matchingPegs.length === 1) { + return matchingPegs[0]; + } + + return null; +} + +function inferWrappedAssetLabel(expectedSymbol: string, actualSymbol: string): string | null { + const expected = normalizeSymbol(expectedSymbol); + const actual = normalizeSymbol(actualSymbol); + + const wrapperPrefixes = ['cb', 'wst', 'we', 'k', 'gt', 'os', 'rs', 'apx', 'bsd', 'lb', 'eb']; + for (const prefix of wrapperPrefixes) { + if (expected.startsWith(prefix) && expected.slice(prefix.length) === actual) { + return `${expectedSymbol} <> ${actualSymbol} peg`; + } + if (actual.startsWith(prefix) && actual.slice(prefix.length) === expected) { + return `${expectedSymbol} <> ${actualSymbol} peg`; + } + } + + return null; } function inferAssumptionLabel(expectedSymbol: string, actualSymbol: string): string | null { @@ -345,6 +374,11 @@ function inferAssumptionLabel(expectedSymbol: string, actualSymbol: string): str return null; } + const wrappedAssetLabel = inferWrappedAssetLabel(expectedSymbol, actualSymbol); + if (wrappedAssetLabel) { + return wrappedAssetLabel; + } + const expectedPeg = getPegAnchor(expectedSymbol); const actualPeg = getPegAnchor(actualSymbol); @@ -456,7 +490,7 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, } else { missingPath = `Oracle uses ${actualPath.toUpperCase()} instead of ${expectedPath.toUpperCase()}. Depegs or divergence won't be reflected.`; if (inferredAssumptions.length > 0) { - missingPath += ` Monarch infers hardcoded assumptions: ${inferredAssumptions.join(' and ')}.`; + missingPath += `\nHardcoded assumptions: ${inferredAssumptions.join(', ')}.`; } } From 30c771da77d9cad86534380f84aa2ef32c9b6649 Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Mon, 20 Apr 2026 01:22:33 +0800 Subject: [PATCH 3/7] fix: infer assumptions from peg metadata only --- src/data-sources/subgraph/market.ts | 13 ++++++---- src/utils/oracle.ts | 37 ++++++++--------------------- src/utils/tokens.ts | 4 ++++ 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index dd823959..37102d33 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -48,11 +48,16 @@ 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; } - return majorPrices[peg]; }; const mapToken = (token: Partial | undefined) => ({ diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index 2558cf0c..4fb8abac 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -335,6 +335,8 @@ function getPegAnchor(symbol: string): TokenPeg | null { if (normalized === 'usd') return TokenPeg.USD; if (normalized === 'eth' || normalized === 'weth') return TokenPeg.ETH; if (normalized === 'btc') return TokenPeg.BTC; + if (normalized === 'xrp') return TokenPeg.XRP; + if (normalized === 'hype') return TokenPeg.HYPE; const matchingPegs = Array.from( new Set( @@ -345,40 +347,21 @@ function getPegAnchor(symbol: string): TokenPeg | null { ), ); - if (matchingPegs.length === 1) { - return matchingPegs[0]; - } - - return null; -} - -function inferWrappedAssetLabel(expectedSymbol: string, actualSymbol: string): string | null { - const expected = normalizeSymbol(expectedSymbol); - const actual = normalizeSymbol(actualSymbol); - - const wrapperPrefixes = ['cb', 'wst', 'we', 'k', 'gt', 'os', 'rs', 'apx', 'bsd', 'lb', 'eb']; - for (const prefix of wrapperPrefixes) { - if (expected.startsWith(prefix) && expected.slice(prefix.length) === actual) { - return `${expectedSymbol} <> ${actualSymbol} peg`; - } - if (actual.startsWith(prefix) && actual.slice(prefix.length) === expected) { - return `${expectedSymbol} <> ${actualSymbol} peg`; - } - } - - return null; + return matchingPegs.length === 1 ? matchingPegs[0] : null; } +/** + * 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 (normalizeEquivalentSymbol(expectedSymbol) === normalizeEquivalentSymbol(actualSymbol)) { return null; } - const wrappedAssetLabel = inferWrappedAssetLabel(expectedSymbol, actualSymbol); - if (wrappedAssetLabel) { - return wrappedAssetLabel; - } - const expectedPeg = getPegAnchor(expectedSymbol); const actualPeg = getPegAnchor(actualSymbol); diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 850da2b8..7d7a6568 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -14,6 +14,8 @@ export enum TokenPeg { USD = 'USD', ETH = 'ETH', BTC = 'BTC', + XRP = 'XRP', + HYPE = 'HYPE', } export type ERC20Token = { @@ -781,6 +783,7 @@ const supportedTokens = [ address: '0x5555555555555555555555555555555555555555', }, ], + peg: TokenPeg.HYPE, }, { symbol: 'UETH', @@ -823,6 +826,7 @@ const supportedTokens = [ img: require('../imgs/tokens/cbxrp.png') as string, decimals: 6, networks: [{ chain: base, address: '0xcb585250f852C6c6bf90434AB21A00f02833a4af' }], + peg: TokenPeg.XRP, }, { symbol: 'cbADA', From 02f5db2b7c24d65131a720df3dccda9e9b3d6b9c Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Mon, 20 Apr 2026 12:40:57 +0800 Subject: [PATCH 4/7] fix: tighten oracle path warnings --- src/data-sources/subgraph/market.ts | 20 ++++++------ src/utils/oracle.ts | 47 ++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 37102d33..82cf12a2 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -60,6 +60,15 @@ const transformSubgraphMarketToMarket = ( } }; + const fillMissingPrice = (currentPrice: number, token: ERC20Token | UnknownERC20Token | undefined): number => { + if (currentPrice > 0 || !token) { + return currentPrice; + } + + const estimatedPrice = getEstimateValue(token); + return estimatedPrice === undefined ? currentPrice : estimatedPrice; + }; + const mapToken = (token: Partial | undefined) => ({ id: token?.id ?? '0x', address: token?.id ?? '0x', @@ -112,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; diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index 4fb8abac..7d4078da 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -313,10 +313,24 @@ type CheckFeedsPathResult = { // to suppress warnings for, not general peg-equivalent assets. const SAME_FAMILY_SYMBOL_GROUPS: Record = { weth: 'eth', + whype: 'hype', usds: 'maker-usd', dai: 'maker-usd', }; +// Non-token symbols (and a few canonical aliases) that can appear in oracle paths. +// Registered ERC20s should resolve through supportedTokens + peg metadata instead. +const PEG_ANCHOR_SYMBOLS: Partial> = { + 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 */ @@ -331,12 +345,10 @@ function normalizeEquivalentSymbol(symbol: string): string { function getPegAnchor(symbol: string): TokenPeg | null { const normalized = normalizeSymbol(symbol); - - if (normalized === 'usd') return TokenPeg.USD; - if (normalized === 'eth' || normalized === 'weth') return TokenPeg.ETH; - if (normalized === 'btc') return TokenPeg.BTC; - if (normalized === 'xrp') return TokenPeg.XRP; - if (normalized === 'hype') return TokenPeg.HYPE; + const canonicalAnchor = PEG_ANCHOR_SYMBOLS[normalized]; + if (canonicalAnchor !== undefined) { + return canonicalAnchor; + } const matchingPegs = Array.from( new Set( @@ -372,6 +384,19 @@ function inferAssumptionLabel(expectedSymbol: string, actualSymbol: string): str return `${expectedSymbol} <> ${actualSymbol} peg`; } +function formatPathMismatchWarning(actualPath: 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}. Missing legs: ${inferredAssumptions.join(', ')}.`; + } + + return `Oracle path mismatch: ${formattedPath}.`; +} + function cancelOutAssets(numeratorAssets: string[], denominatorAssets: string[], areEquivalent: (left: string, right: string) => boolean) { const remainingDenominatorAssets = [...denominatorAssets]; const remainingNumeratorAssets: string[] = []; @@ -467,15 +492,7 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, : null, ].filter((value): value is string => Boolean(value)); - let missingPath = ''; - if (exactCancellation.remainingNumeratorAssets.length === 0 && exactCancellation.remainingDenominatorAssets.length === 0) { - missingPath = 'All assets canceled out - no price path found'; - } else { - missingPath = `Oracle uses ${actualPath.toUpperCase()} instead of ${expectedPath.toUpperCase()}. Depegs or divergence won't be reflected.`; - if (inferredAssumptions.length > 0) { - missingPath += `\nHardcoded assumptions: ${inferredAssumptions.join(', ')}.`; - } - } + const missingPath = formatPathMismatchWarning(actualPath, inferredAssumptions); return { isValid: false, From f5d7d9a8ba49cd52a4856e408f9eaa0feec29cc6 Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Mon, 20 Apr 2026 12:43:14 +0800 Subject: [PATCH 5/7] fix: simplify oracle assumption warning copy --- src/utils/oracle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index 7d4078da..0109d1a3 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -391,7 +391,7 @@ function formatPathMismatchWarning(actualPath: string, inferredAssumptions: stri const formattedPath = actualPath.toUpperCase(); if (inferredAssumptions.length > 0) { - return `Oracle has hardcoded path: ${formattedPath}. Missing legs: ${inferredAssumptions.join(', ')}.`; + return `Oracle has hardcoded path: ${formattedPath}. This assumes ${inferredAssumptions.join(' and ')}.`; } return `Oracle path mismatch: ${formattedPath}.`; From 4bf763eb25e504ca8034005979f1edace880190e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 20 Apr 2026 13:34:54 +0800 Subject: [PATCH 6/7] chore: cleanup --- src/utils/oracle.ts | 38 ++++++++++---------------------------- src/utils/tokens.ts | 7 +++++-- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index 0109d1a3..6d6765d4 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -308,18 +308,10 @@ type CheckFeedsPathResult = { inferredAssumptions?: string[]; }; -// Symbols in the same UI-resolution group are treated as already resolved for path validation. -// Keep this list extremely small: only exact wrappers / naming continuations we explicitly want -// to suppress warnings for, not general peg-equivalent assets. -const SAME_FAMILY_SYMBOL_GROUPS: Record = { - weth: 'eth', - whype: 'hype', - usds: 'maker-usd', - dai: 'maker-usd', -}; - // Non-token symbols (and a few canonical aliases) that can appear in oracle paths. -// Registered ERC20s should resolve through supportedTokens + peg metadata instead. +// 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> = { usd: TokenPeg.USD, eth: TokenPeg.ETH, @@ -335,12 +327,8 @@ const PEG_ANCHOR_SYMBOLS: Partial> = { * Normalize asset symbols for comparison */ function normalizeSymbol(symbol: string): string { - return symbol.toLowerCase(); -} - -function normalizeEquivalentSymbol(symbol: string): string { - const normalized = normalizeSymbol(symbol); - return SAME_FAMILY_SYMBOL_GROUPS[normalized] ?? normalized; + const normalized = symbol.toLowerCase(); + return normalized === 'weth' ? 'eth' : normalized; } function getPegAnchor(symbol: string): TokenPeg | null { @@ -370,7 +358,7 @@ function getPegAnchor(symbol: string): TokenPeg | null { * anchor, we surface that missing conversion as an assumption. */ function inferAssumptionLabel(expectedSymbol: string, actualSymbol: string): string | null { - if (normalizeEquivalentSymbol(expectedSymbol) === normalizeEquivalentSymbol(actualSymbol)) { + if (normalizeSymbol(expectedSymbol) === normalizeSymbol(actualSymbol)) { return null; } @@ -458,14 +446,8 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, denominatorAssets, (left, right) => normalizeSymbol(left) === normalizeSymbol(right), ); - const equivalentCancellation = cancelOutAssets( - numeratorAssets, - denominatorAssets, - (left, right) => normalizeEquivalentSymbol(left) === normalizeEquivalentSymbol(right), - ); - - const remainingNumeratorAssets = equivalentCancellation.remainingNumeratorAssets; - const remainingDenominatorAssets = equivalentCancellation.remainingDenominatorAssets; + const remainingNumeratorAssets = exactCancellation.remainingNumeratorAssets; + const remainingDenominatorAssets = exactCancellation.remainingDenominatorAssets; const normalizedCollateralSymbol = normalizeSymbol(collateralSymbol); const normalizedLoanSymbol = normalizeSymbol(loanSymbol); @@ -474,8 +456,8 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, const isValid = remainingNumeratorAssets.length === 1 && remainingDenominatorAssets.length === 1 && - normalizeEquivalentSymbol(remainingNumeratorAssets[0]) === normalizeEquivalentSymbol(collateralSymbol) && - normalizeEquivalentSymbol(remainingDenominatorAssets[0]) === normalizeEquivalentSymbol(loanSymbol); + normalizeSymbol(remainingNumeratorAssets[0]) === normalizedCollateralSymbol && + normalizeSymbol(remainingDenominatorAssets[0]) === normalizedLoanSymbol; if (isValid) { return { isValid: true }; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 7d7a6568..6e76a24d 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -9,7 +9,9 @@ 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', @@ -29,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; }; From ed14c905ee2fe2c62cc42980f8239f17fad8974d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 20 Apr 2026 14:22:16 +0800 Subject: [PATCH 7/7] chore: whype --- src/utils/oracle.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index 6d6765d4..2b695986 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -328,7 +328,9 @@ const PEG_ANCHOR_SYMBOLS: Partial> = { */ 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 { @@ -372,7 +374,7 @@ function inferAssumptionLabel(expectedSymbol: string, actualSymbol: string): str return `${expectedSymbol} <> ${actualSymbol} peg`; } -function formatPathMismatchWarning(actualPath: string, inferredAssumptions: string[]): string { +function formatPathMismatchWarning(actualPath: string, expectedPath: string, inferredAssumptions: string[]): string { if (actualPath === 'EMPTY/EMPTY') { return 'Oracle path mismatch: no price path found.'; } @@ -382,7 +384,7 @@ function formatPathMismatchWarning(actualPath: string, inferredAssumptions: stri return `Oracle has hardcoded path: ${formattedPath}. This assumes ${inferredAssumptions.join(' and ')}.`; } - return `Oracle path mismatch: ${formattedPath}.`; + 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) { @@ -452,6 +454,7 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, const normalizedCollateralSymbol = normalizeSymbol(collateralSymbol); const normalizedLoanSymbol = normalizeSymbol(loanSymbol); const expectedPath = `${normalizedCollateralSymbol}/${normalizedLoanSymbol}`; + const expectedDisplayPath = `${collateralSymbol}/${loanSymbol}`; const isValid = remainingNumeratorAssets.length === 1 && @@ -474,7 +477,7 @@ function validateFeedPaths(feedPaths: FeedPathEntry[], collateralSymbol: string, : null, ].filter((value): value is string => Boolean(value)); - const missingPath = formatPathMismatchWarning(actualPath, inferredAssumptions); + const missingPath = formatPathMismatchWarning(actualPath, expectedDisplayPath, inferredAssumptions); return { isValid: false,