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
14 changes: 14 additions & 0 deletions src/abis/chainlink-aggregator-v3.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
63 changes: 60 additions & 3 deletions src/features/markets/components/oracle/MarketOracle/FeedEntry.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,6 +10,7 @@ import type { FeedSnapshotByAddress } from '@/hooks/useFeedLastUpdatedByChain';
import type { EnrichedFeed } from '@/hooks/useOracleMetadata';
import {
detectFeedVendorFromMetadata,
formatOraclePrice,
getFeedFreshnessStatus,
getTruncatedAssetName,
OracleVendorIcons,
Expand All @@ -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;
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -123,6 +178,7 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr
<UnknownFeedTooltip
feed={feed}
chainId={chainId}
feedFreshness={freshness}
/>
);

Expand All @@ -131,6 +187,7 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr
<UnknownFeedTooltip
feed={feed}
chainId={chainId}
feedFreshness={freshness}
/>
);
}
Expand All @@ -139,7 +196,7 @@ export function FeedEntry({ feed, chainId, feedSnapshotsByAddress }: FeedEntryPr
return (
<Tooltip
content={getTooltipContent()}
className="w-fit max-w-[calc(100vw-2rem)]"
className="min-w-[12rem] max-w-[calc(100vw-2rem)] [&>div]:!w-full [&>div]:min-w-[12rem] [&>div]:gap-4"
>
<div className="bg-hovered flex w-full cursor-pointer items-center justify-between rounded-sm px-2 py-1 hover:bg-opacity-80 gap-1">
{showAssetPair ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
<div className={cn('space-y-1 border-t border-gray-200/30 pt-2 dark:border-gray-600/20', className)}>
<div className={cn('space-y-2 border-t border-gray-200/30 pt-3 dark:border-gray-600/20', className)}>
{normalizedPrice != null && (
<div className="flex items-center justify-between gap-1">
<span className="font-zen text-xs text-gray-600 dark:text-gray-400">Price:</span>
<span className="font-zen text-xs font-medium">{normalizedPrice}</span>
<div className="flex items-center justify-between font-zen text-sm">
<span className="text-gray-600 dark:text-gray-400">Feed Price:</span>
<span className="font-medium tabular-nums">{normalizedPrice}</span>
</div>
)}

{updatedAt != null && (
<div className="flex items-center justify-between gap-1">
<span className="font-zen text-xs text-gray-600 dark:text-gray-400">Last Updated:</span>
<span className="whitespace-nowrap text-right font-zen text-xs font-medium">{formatOracleTimestamp(updatedAt)}</span>
</div>
)}

{feedFreshness.ageSeconds != null && (
<div className="flex items-center justify-between gap-1">
<span className="font-zen text-xs text-gray-600 dark:text-gray-400">Age:</span>
<span className="text-right font-zen text-xs font-medium">{formatOracleDuration(feedFreshness.ageSeconds)} ago</span>
{ageSeconds != null && (
<div className="flex items-center justify-between font-zen text-sm">
<span className="text-gray-600 dark:text-gray-400">Age:</span>
<span className="font-medium">{formatOracleDuration(ageSeconds)} ago</span>
</div>
)}

{isDerived && (
<div className="flex items-center justify-between gap-1">
<span className="font-zen text-xs text-gray-600 dark:text-gray-400">Mode:</span>
<div className="flex items-center justify-between font-zen text-sm">
<span className="text-gray-600 dark:text-gray-400">Mode:</span>
<Badge
variant="primary"
size="sm"
Expand All @@ -54,9 +47,9 @@ export function FeedFreshnessSection({ feedFreshness, className }: FeedFreshness
)}

{feedFreshness.isStale && (
<div className="flex items-center justify-between gap-1">
<span className="font-zen text-xs text-gray-600 dark:text-gray-400">Status:</span>
<span className="text-right font-zen text-xs font-medium text-yellow-700 dark:text-yellow-300">Stale</span>
<div className="flex items-center justify-between font-zen text-sm">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="font-medium text-yellow-700 dark:text-yellow-300">Stale</span>
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex w-fit max-w-[22rem] flex-col gap-3">
{/* Header with icon and title */}
Expand All @@ -26,6 +29,8 @@ export function UnknownFeedTooltip({ feed, chainId }: UnknownFeedTooltipProps) {
{/* Description */}
<div className="font-zen text-sm text-gray-600 dark:text-gray-400">This oracle uses an unrecognized price feed contract.</div>

<FeedFreshnessSection feedFreshness={feedFreshness} />

{/* External Links */}
<div className="border-t border-gray-200/30 pt-3 dark:border-gray-600/20">
<div className="mb-2 font-zen text-sm font-medium text-gray-700 dark:text-gray-300">View contract:</div>
Expand Down
12 changes: 9 additions & 3 deletions src/utils/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type OracleMetadataRecord,
type OracleOutputData,
} from '@/hooks/useOracleMetadata';
import { formatSimple } from './balance';
import { SupportedNetworks } from './networks';

type VendorInfo = {
Expand Down Expand Up @@ -494,19 +495,24 @@ 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;

const [integerPart, fractionPart = ''] = raw.split('.');
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;
}

Expand Down