diff --git a/src/abis/chainlink-aggregator-v3.ts b/src/abis/chainlink-aggregator-v3.ts index 1395c42f..45d01220 100644 --- a/src/abis/chainlink-aggregator-v3.ts +++ b/src/abis/chainlink-aggregator-v3.ts @@ -1,6 +1,20 @@ import type { Abi } from 'viem'; export const chainlinkAggregatorV3Abi = [ + { + inputs: [], + name: 'latestAnswer', + outputs: [{ internalType: 'int256', name: '', type: 'int256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'latestTimestamp', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, { inputs: [], name: 'latestRoundData', diff --git a/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx b/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx index 188f1dee..a4e0dfd9 100644 --- a/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx +++ b/src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx @@ -1,4 +1,7 @@ import { useMemo } from 'react'; +import type { Address } from 'viem'; +import { useReadContracts } from 'wagmi'; +import { chainlinkAggregatorV3Abi } from '@/abis/chainlink-aggregator-v3'; import { Tooltip } from '@/components/ui/tooltip'; import Image from 'next/image'; import { IoIosSwap } from 'react-icons/io'; @@ -7,6 +10,7 @@ import type { FeedSnapshotByAddress } from '@/hooks/useFeedLastUpdatedByChain'; import type { EnrichedFeed } from '@/hooks/useOracleMetadata'; import { detectFeedVendorFromMetadata, + formatOraclePrice, getFeedFreshnessStatus, getTruncatedAssetName, OracleVendorIcons, @@ -31,6 +35,44 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr return detectFeedVendorFromMetadata(feed); }, [feed]); + const directReadContracts = useMemo(() => { + if (!feed?.address) return []; + + const address = feed.address as Address; + + return [ + { + address, + abi: chainlinkAggregatorV3Abi, + functionName: 'latestAnswer' as const, + chainId, + }, + { + address, + abi: chainlinkAggregatorV3Abi, + functionName: 'latestTimestamp' as const, + chainId, + }, + { + address, + abi: chainlinkAggregatorV3Abi, + functionName: 'decimals' as const, + chainId, + }, + ]; + }, [chainId, feed?.address]); + + const { data: directReadResults } = useReadContracts({ + contracts: directReadContracts, + allowFailure: true, + query: { + enabled: directReadContracts.length > 0, + staleTime: 60_000, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + }, + }); + if (!feed) return null; const { vendor, assetPair } = feedVendorResult; @@ -46,9 +88,22 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr const hasKnownVendorIcon = vendor !== PriceFeedVendors.Unknown && Boolean(vendorIcon); const feedAddressKey = feed.address.toLowerCase(); const snapshot = feedSnapshotsByAddress?.[feedAddressKey]; - const freshness = getFeedFreshnessStatus(snapshot?.updatedAt ?? null, feed.heartbeat, { + const directAnswer = + directReadResults?.[0]?.status === 'success' && typeof directReadResults[0].result === 'bigint' ? directReadResults[0].result : null; + const directTimestamp = + directReadResults?.[1]?.status === 'success' && typeof directReadResults[1].result === 'bigint' && directReadResults[1].result > 0n + ? Number(directReadResults[1].result) + : null; + const directDecimals = + directReadResults?.[2]?.status === 'success' && Number.isFinite(Number(directReadResults[2].result)) + ? Number(directReadResults[2].result) + : (feed.decimals ?? null); + const directNormalizedPrice = + directAnswer != null && directDecimals != null ? formatOraclePrice(directAnswer, directDecimals) : (snapshot?.normalizedPrice ?? null); + + const freshness = getFeedFreshnessStatus(directTimestamp ?? snapshot?.updatedAt ?? null, feed.heartbeat, { updateKind: snapshot?.updateKind, - normalizedPrice: snapshot?.normalizedPrice, + normalizedPrice: directNormalizedPrice, }); const getTooltipContent = () => { @@ -123,6 +178,7 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr ); @@ -131,6 +187,7 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr ); } @@ -139,7 +196,7 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr return (
{showAssetPair ? ( diff --git a/src/features/markets/components/oracle/MarketOracle/FeedFreshnessSection.tsx b/src/features/markets/components/oracle/MarketOracle/FeedFreshnessSection.tsx index e0440000..b3c9297a 100644 --- a/src/features/markets/components/oracle/MarketOracle/FeedFreshnessSection.tsx +++ b/src/features/markets/components/oracle/MarketOracle/FeedFreshnessSection.tsx @@ -1,6 +1,6 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/utils/components'; -import { formatOracleDuration, formatOracleTimestamp, type FeedFreshnessStatus } from '@/utils/oracle'; +import { formatOracleDuration, type FeedFreshnessStatus } from '@/utils/oracle'; type FeedFreshnessSectionProps = { feedFreshness?: FeedFreshnessStatus; @@ -10,39 +10,32 @@ type FeedFreshnessSectionProps = { export function FeedFreshnessSection({ feedFreshness, className }: FeedFreshnessSectionProps) { if (!feedFreshness) return null; - const updatedAt = feedFreshness.updatedAt; const normalizedPrice = feedFreshness.normalizedPrice; - const hasTimestamp = updatedAt != null; + const ageSeconds = feedFreshness.ageSeconds; const hasPrice = normalizedPrice != null; + const hasAge = ageSeconds != null; const isDerived = feedFreshness.updateKind === 'derived'; - if (!hasTimestamp && !hasPrice && !isDerived) return null; + if (!hasAge && !hasPrice && !isDerived) return null; return ( -
+
{normalizedPrice != null && ( -
- Price: - {normalizedPrice} +
+ Feed Price: + {normalizedPrice}
)} - {updatedAt != null && ( -
- Last Updated: - {formatOracleTimestamp(updatedAt)} -
- )} - - {feedFreshness.ageSeconds != null && ( -
- Age: - {formatOracleDuration(feedFreshness.ageSeconds)} ago + {ageSeconds != null && ( +
+ Age: + {formatOracleDuration(ageSeconds)} ago
)} {isDerived && ( -
- Mode: +
+ Mode: - Status: - Stale +
+ Status: + Stale
)}
diff --git a/src/features/markets/components/oracle/MarketOracle/UnknownFeedTooltip.tsx b/src/features/markets/components/oracle/MarketOracle/UnknownFeedTooltip.tsx index e60793a0..5021cde0 100644 --- a/src/features/markets/components/oracle/MarketOracle/UnknownFeedTooltip.tsx +++ b/src/features/markets/components/oracle/MarketOracle/UnknownFeedTooltip.tsx @@ -5,13 +5,16 @@ import type { Address } from 'viem'; import type { EnrichedFeed } from '@/hooks/useOracleMetadata'; import etherscanLogo from '@/imgs/etherscan.png'; import { getExplorerURL } from '@/utils/external'; +import type { FeedFreshnessStatus } from '@/utils/oracle'; +import { FeedFreshnessSection } from './FeedFreshnessSection'; type UnknownFeedTooltipProps = { feed: EnrichedFeed; chainId: number; + feedFreshness?: FeedFreshnessStatus; }; -export function UnknownFeedTooltip({ feed, chainId }: UnknownFeedTooltipProps) { +export function UnknownFeedTooltip({ feed, chainId, feedFreshness }: UnknownFeedTooltipProps) { return (
{/* Header with icon and title */} @@ -26,6 +29,8 @@ export function UnknownFeedTooltip({ feed, chainId }: UnknownFeedTooltipProps) { {/* Description */}
This oracle uses an unrecognized price feed contract.
+ + {/* External Links */}
View contract:
diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index 9b0f6d30..2b0f188d 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -18,6 +18,7 @@ import { type OracleMetadataRecord, type OracleOutputData, } from '@/hooks/useOracleMetadata'; +import { formatSimple } from './balance'; import { SupportedNetworks } from './networks'; type VendorInfo = { @@ -494,11 +495,16 @@ export function formatOracleTimestampCompact(seconds: number): string { } /** - * Format raw feed answer using feed decimals into a compact plain decimal value. + * Format raw feed answer using the shared simple-number display style. */ -export function formatOraclePrice(answerRaw: bigint, decimals: number, maxFractionDigits = 8): string { +export function formatOraclePrice(answerRaw: bigint, decimals: number): string { const safeDecimals = Number.isFinite(decimals) ? Math.max(0, Math.min(36, Math.floor(decimals))) : 8; const raw = formatUnits(answerRaw, safeDecimals); + const numericValue = Number(raw); + + if (Number.isFinite(numericValue)) { + return formatSimple(numericValue); + } if (!raw.includes('.')) return raw; @@ -506,7 +512,7 @@ export function formatOraclePrice(answerRaw: bigint, decimals: number, maxFracti const trimmedFraction = fractionPart.replace(/0+$/, ''); if (!trimmedFraction) return integerPart; - const cappedFraction = trimmedFraction.slice(0, maxFractionDigits).replace(/0+$/, ''); + const cappedFraction = trimmedFraction.slice(0, 4).replace(/0+$/, ''); return cappedFraction ? `${integerPart}.${cappedFraction}` : integerPart; }