From 345e3f7ee980d723a272ce8d38fafeb8638ce8b0 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 10 Mar 2026 13:49:08 +0800 Subject: [PATCH 1/2] fix: model selection and lower the fee --- AGENTS.md | 1 + src/config/fees.ts | 4 +- .../markets/components/market-indicators.tsx | 5 +- .../components/markets-table-same-loan.tsx | 11 +- .../components/rebalance/rebalance-modal.tsx | 176 ++++++++++++++++-- src/hooks/useSmartRebalance.ts | 56 +++++- .../add-collateral-and-leverage.tsx | 41 ++-- src/utils/assetDisplay.ts | 53 ++++++ 8 files changed, 304 insertions(+), 43 deletions(-) create mode 100644 src/utils/assetDisplay.ts diff --git a/AGENTS.md b/AGENTS.md index 25ad3f79..e1a0a985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,7 @@ When touching transaction and position flows, validation MUST include all releva 27. **Transaction-tracking preflight integrity**: do not call `tracking.start(...)` until all synchronous preflight validation for the flow has passed (account, route, quote, input, fee viability). Once tracking has started, execution helpers must either complete successfully or throw so the caller can finish the lifecycle with exactly one `tracking.complete()` or `tracking.fail()`. 28. **Close-route collateral handoff integrity**: when a deleverage projection derives an exact close-bound collateral amount for full-repay-by-shares, route-specific executors must receive and use that quote-derived close bound explicitly for withdraw/redeem steps instead of relying on the raw user input amount. Any remaining collateral must be returned through the dedicated post-close withdraw/sweep path. 29. **Preview prop integrity**: any position/risk preview component that separates current and projected props must receive quote- or input-derived projected balances through dedicated `projected*` props while preserving live balances in `current*` props, so amount rows, LTV deltas, and liquidation metrics stay synchronized instead of mixing current and projected states. +30. **Fee preview consistency**: transaction previews that show protocol/app fees must derive token/USD display from shared fee-display helpers, use compact token amounts with explicit full-value hover content, threshold tiny USD values as `< $0.01` while preserving exact USD on hover, and avoid ad hoc per-modal formatting drift. ### REQUIRED: Regression Rule Capture diff --git a/src/config/fees.ts b/src/config/fees.ts index 21089875..53b840c3 100644 --- a/src/config/fees.ts +++ b/src/config/fees.ts @@ -1,9 +1,9 @@ import { parseUnits } from 'viem'; const FEE_DENOMINATOR_PPM = 1_000_000n; -const REBALANCE_FEE_RATE_PPM = 40n; // 0.4 bps = 0.004% +const REBALANCE_FEE_RATE_PPM = 30n; // 0.3 bps = 0.003% const LEVERAGE_FEE_RATE_PPM = 75n; // 0.75 bps = 0.0075% -export const REBALANCE_FEE_CEILING_USD = 4; +export const REBALANCE_FEE_CEILING_USD = 10; export const LEVERAGE_FEE_CEILING_USD = 5; type FeeParams = { diff --git a/src/features/markets/components/market-indicators.tsx b/src/features/markets/components/market-indicators.tsx index 5e7ab2b5..62e7b906 100644 --- a/src/features/markets/components/market-indicators.tsx +++ b/src/features/markets/components/market-indicators.tsx @@ -14,7 +14,7 @@ import { useMarketMetricsMap, type FlowTimeWindow, } from '@/hooks/queries/useMarketMetricsQuery'; -import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; +import { useMarketWarnings } from '@/hooks/useMarketWarnings'; import { useMarketPreferences, type CustomTagConfig } from '@/stores/useMarketPreferences'; import type { Market } from '@/utils/types'; import { RewardsIndicator } from '@/features/markets/components/rewards-indicator'; @@ -88,7 +88,8 @@ export function MarketIndicators({ market, showRisk = false, isStared = false, h const customTagKeys = useCustomTagMarketKeys(); const hasCustomTag = customTagConfig.enabled && customTagKeys.has(marketKey); - const warnings = showRisk ? computeMarketWarnings(market, { considerWhitelist: true }) : []; + const marketWarnings = useMarketWarnings(showRisk ? market : null); + const warnings = showRisk ? marketWarnings : []; const hasWarnings = warnings.length > 0; const alertWarning = warnings.find((w) => w.level === 'alert'); const warningLevel = alertWarning ? 'alert' : warnings.length > 0 ? 'warning' : null; diff --git a/src/features/markets/components/markets-table-same-loan.tsx b/src/features/markets/components/markets-table-same-loan.tsx index 586cbfa9..130de2db 100644 --- a/src/features/markets/components/markets-table-same-loan.tsx +++ b/src/features/markets/components/markets-table-same-loan.tsx @@ -15,6 +15,7 @@ import { TrustedByCell } from '@/features/autovault/components/trusted-vault-bad import { getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; import { useFreshMarketsState } from '@/hooks/useFreshMarketsState'; import { useModal } from '@/hooks/useModal'; +import { useAllOracleMetadata } from '@/hooks/useOracleMetadata'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useTrustedVaults } from '@/stores/useTrustedVaults'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; @@ -317,6 +318,7 @@ export function MarketsTableWithSameLoanAsset({ // Get global market settings const { showUnwhitelistedMarkets, isAprDisplay } = useAppSettings(); const { findToken } = useTokensQuery(); + const { data: oracleMetadataMap } = useAllOracleMetadata(); const { label: supplyRateLabel } = useRateLabel({ prefix: 'Supply' }); const { label: borrowRateLabel } = useRateLabel({ prefix: 'Borrow' }); @@ -453,14 +455,17 @@ export function MarketsTableWithSameLoanAsset({ markets.forEach((m) => { if (!m?.market?.morphoBlue?.chain?.id) return; - const vendorInfo = parsePriceFeedVendors(m.market.oracle?.data, m.market.morphoBlue.chain.id); + const vendorInfo = parsePriceFeedVendors(m.market.oracle?.data, m.market.morphoBlue.chain.id, { + metadataMap: oracleMetadataMap, + oracleAddress: m.market.oracleAddress, + }); if (vendorInfo?.coreVendors) { vendorInfo.coreVendors.forEach((vendor) => oracleSet.add(vendor)); } }); return Array.from(oracleSet); - }, [markets]); + }, [markets, oracleMetadataMap]); // Filter and sort markets using the new shared filtering system const processedMarkets = useMemo(() => { @@ -489,6 +494,7 @@ export function MarketsTableWithSameLoanAsset({ }, findToken, searchQuery, + oracleMetadataMap, }); // Apply whitelist filter (not in the shared utility because it uses global state) @@ -542,6 +548,7 @@ export function MarketsTableWithSameLoanAsset({ usdFilters, findToken, hasTrustedVault, + oracleMetadataMap, trustedVaultsOnly, ]); diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index eadb634f..c13617d1 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -5,10 +5,13 @@ import { parseUnits, formatUnits } from 'viem'; import { Button } from '@/components/ui/button'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; import { ModalIntentSwitcher } from '@/components/common/Modal/ModalIntentSwitcher'; +import { HelpTooltipIcon } from '@/components/shared/help-tooltip-icon'; import { Spinner } from '@/components/ui/spinner'; import { TokenIcon } from '@/components/shared/token-icon'; +import { TooltipContent as SharedTooltipContent } from '@/components/shared/tooltip-content'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { Badge } from '@/components/ui/badge'; +import { Tooltip } from '@/components/ui/tooltip'; import { useModalStore } from '@/stores/useModalStore'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; import { useRebalance } from '@/hooks/useRebalance'; @@ -19,11 +22,15 @@ import { useRateLabel } from '@/hooks/useRateLabel'; import type { Market } from '@/utils/types'; import type { GroupedPosition, RebalanceAction } from '@/utils/types'; import { formatBalance, formatReadable } from '@/utils/balance'; +import { formatUsdValue } from '@/utils/portfolio'; +import { formatUsdValueDisplay } from '@/utils/assetDisplay'; import { convertApyToApr } from '@/utils/rateMath'; import { calculateSmartRebalancePlan, type SmartRebalancePlan } from '@/features/positions/smart-rebalance/planner'; import type { SmartRebalanceConstraintMap } from '@/features/positions/smart-rebalance/types'; import type { TransactionSummaryItem } from '@/stores/useTransactionProcessStore'; import type { SupportedNetworks } from '@/utils/networks'; +import { formatTokenAmountPreview } from '@/hooks/leverage/math'; +import { REBALANCE_FEE_CEILING_USD } from '@/config/fees'; import { RiSparklingFill } from 'react-icons/ri'; import { FiTrash2 } from 'react-icons/fi'; import { AllocationCell } from '../allocation-cell'; @@ -50,7 +57,8 @@ const SMART_REBALANCE_RECALC_DEBOUNCE_MS = 300; const MAX_ALLOCATION_PERCENT_MIN = 0; const MAX_ALLOCATION_PERCENT_MAX = 100; const MAX_ALLOCATION_PERCENT_STEP = 0.5; -const SMART_REBALANCE_FEE_LABEL = 'Fee (0.004%)'; +const SMART_REBALANCE_FEE_LABEL = 'Fee'; +const INLINE_VALUE_TOOLTIP_CLASS_NAME = 'px-4 py-3 text-xs'; function formatPercent(value: number, digits = 2): string { return `${value.toFixed(digits)}%`; @@ -63,6 +71,11 @@ function formatRate(apy: number, isAprDisplay: boolean): string { return formatPercent(displayRate * 100, 2); } +function formatDailyEarning(value: number): string { + const formattedValue = formatUsdValue(value, value < 1 ? 4 : 2); + return `${formattedValue}/day`; +} + function formatMaxAllocationInput(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(1); } @@ -79,7 +92,8 @@ function parseMaxAllocationInput(raw: string): number | null { } type PreviewRow = { - label: string; + id: string; + label: ReactNode; value: ReactNode; valueClassName?: string; }; @@ -91,7 +105,7 @@ function PreviewSection({ title, rows }: { title: string; rows: PreviewRow[] })
{rows.map((row) => (
{row.label} @@ -149,6 +163,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, isProcessing: isSmartProcessing, totalMoved: smartTotalMoved, feeAmount: smartFeeAmount, + feeUsdValue: smartFeeUsdValue, + isFeeCapped: smartFeeIsCapped, + estimatedDailyEarningsUsd, } = useSmartRebalance(groupedPosition, smartPlan, handleSmartTxSuccess); const eligibleMarkets = useMemo(() => { @@ -260,6 +277,18 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const smartCurrentWeightedRate = isAprDisplay ? smartCurrentWeightedApr : smartCurrentWeightedApy; const smartProjectedWeightedRate = isAprDisplay ? smartProjectedWeightedApr : smartProjectedWeightedApy; const smartWeightedRateDiff = smartProjectedWeightedRate - smartCurrentWeightedRate; + const smartCapitalMovedPreview = useMemo( + () => formatTokenAmountPreview(smartTotalMoved, groupedPosition.loanAssetDecimals), + [groupedPosition.loanAssetDecimals, smartTotalMoved], + ); + const smartFeePreview = useMemo( + () => formatTokenAmountPreview(smartFeeAmount, groupedPosition.loanAssetDecimals), + [groupedPosition.loanAssetDecimals, smartFeeAmount], + ); + const smartFeeUsdDisplay = useMemo(() => (smartFeeUsdValue == null ? null : formatUsdValueDisplay(smartFeeUsdValue)), [smartFeeUsdValue]); + const smartFeeSummaryDetail = useMemo(() => { + return [smartFeeUsdDisplay?.display, smartFeeIsCapped ? 'capped' : null].filter((part): part is string => part != null).join(' · '); + }, [smartFeeIsCapped, smartFeeUsdDisplay]); const smartSummaryItems = useMemo((): TransactionSummaryItem[] => { if (!smartPlan) return []; @@ -274,31 +303,104 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }, ]; + if (estimatedDailyEarningsUsd !== null) { + items.push({ + id: 'estimated-daily-earning', + label: 'Estimated Daily Earning', + value: formatDailyEarning(estimatedDailyEarningsUsd), + }); + } + if (smartTotalMoved > 0n) { items.push({ id: 'capital-moved', - label: 'Capital moved', + label: 'Capital Moved', value: fmtAmount(smartTotalMoved), }); items.push({ id: 'fee', label: SMART_REBALANCE_FEE_LABEL, - value: fmtAmount(smartFeeAmount), + value: `${smartFeePreview.compact} ${groupedPosition.loanAssetSymbol}`, + detail: smartFeeSummaryDetail || undefined, }); } return items; }, [ fmtAmount, + groupedPosition.loanAssetSymbol, rateLabel, smartCurrentWeightedRate, + estimatedDailyEarningsUsd, smartFeeAmount, + smartFeePreview.compact, + smartFeeSummaryDetail, smartPlan, smartProjectedWeightedRate, smartTotalMoved, smartWeightedRateDiff, ]); + const smartFeePreviewRow = useMemo( + () => ({ + id: 'fee', + label: ( + + Fee + + } + ariaLabel="Explain smart rebalance fee policy" + className="h-auto w-auto" + /> + + ), + value: ( + + + {smartFeePreview.compact} + + {smartFeeUsdDisplay != null && + (smartFeeUsdDisplay.showExactTooltip ? ( + + {smartFeeUsdDisplay.display} + + ) : ( + {smartFeeUsdDisplay.display} + ))} + {smartFeeIsCapped && capped} + + + ), + }), + [ + groupedPosition.chainId, + groupedPosition.loanAssetAddress, + groupedPosition.loanAssetSymbol, + smartFeeIsCapped, + smartFeePreview.compact, + smartFeePreview.full, + smartFeeUsdDisplay, + ], + ); + const smartRows = useMemo(() => { const selectedMarkets = [...smartSelectedMarketKeys] .map((key) => marketByKey.get(key)) @@ -651,9 +753,10 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }); }, [refetch, refreshActionLoading, toast]); - const smartPreviewRows = useMemo( - () => [ + const smartPreviewRows = useMemo(() => { + const rows: PreviewRow[] = [ { + id: 'weighted-rate', label: `Weighted ${rateLabel}`, value: ( <> @@ -665,17 +768,52 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, ), }, - { - label: 'Capital moved', - value: fmtAmount(smartTotalMoved), - }, - { - label: SMART_REBALANCE_FEE_LABEL, - value: fmtAmount(smartFeeAmount), - }, - ], - [fmtAmount, rateLabel, smartCurrentWeightedRate, smartFeeAmount, smartProjectedWeightedRate, smartTotalMoved, smartWeightedRateDiff], - ); + ]; + + if (estimatedDailyEarningsUsd !== null) { + rows.push({ + id: 'estimated-daily-earning', + label: 'Estimated Daily Earning', + value: formatDailyEarning(estimatedDailyEarningsUsd), + valueClassName: 'tabular-nums text-green-600', + }); + } + + rows.push({ + id: 'capital-moved', + label: 'Capital Moved', + value: ( + + + {smartCapitalMovedPreview.compact} + + + + ), + }); + + return rows; + }, [ + estimatedDailyEarningsUsd, + groupedPosition.chainId, + groupedPosition.loanAssetAddress, + groupedPosition.loanAssetSymbol, + rateLabel, + smartCapitalMovedPreview, + smartCurrentWeightedRate, + smartProjectedWeightedRate, + smartTotalMoved, + smartWeightedRateDiff, + ]); return ( )} diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts index 00c064c8..43e719af 100644 --- a/src/hooks/useSmartRebalance.ts +++ b/src/hooks/useSmartRebalance.ts @@ -12,12 +12,16 @@ import type { SmartRebalancePlan } from '@/features/positions/smart-rebalance/pl import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useRebalanceExecution, type RebalanceExecutionStepType } from './useRebalanceExecution'; import { useConnection } from 'wagmi'; +import { computeAssetUsdValue } from '@/utils/assetDisplay'; const FULL_RATE_PPM = 1_000_000n; const SMART_REBALANCE_SHARE_WITHDRAW_DUST_BUFFER = 1000n; +const DAYS_PER_YEAR = 365; type SmartRebalanceFeeBreakdown = { totalFee: bigint; + uncappedTotalFee: bigint; + assetPriceUsd: number | null; feeByMarket: Map; }; @@ -46,7 +50,7 @@ function computeFeeBreakdown( pricedLoanAssetUsd: number | null, ): SmartRebalanceFeeBreakdown { if (!plan) { - return { totalFee: 0n, feeByMarket: new Map() }; + return { totalFee: 0n, uncappedTotalFee: 0n, assetPriceUsd: null, feeByMarket: new Map() }; } const feeByMarket = new Map(); @@ -65,7 +69,7 @@ function computeFeeBreakdown( } if (uncappedTotal === 0n) { - return { totalFee: 0n, feeByMarket }; + return { totalFee: 0n, uncappedTotalFee: 0n, assetPriceUsd: null, feeByMarket }; } const fallbackPriceUsd = deriveLoanAssetPriceUsdFromPlan(plan, loanAssetDecimals); @@ -92,10 +96,40 @@ function computeFeeBreakdown( return { totalFee: cappedTotal, + uncappedTotalFee: uncappedTotal, + assetPriceUsd: effectiveLoanAssetPriceUsd, feeByMarket, }; } +function computeEstimatedDailyEarningsUsd( + plan: SmartRebalancePlan | null, + loanAssetDecimals: number, + pricedLoanAssetUsd: number | null, +): number | null { + if (!plan || pricedLoanAssetUsd == null || !Number.isFinite(pricedLoanAssetUsd) || pricedLoanAssetUsd <= 0) { + return null; + } + + const totalPoolToken = Number(formatUnits(plan.totalPool, loanAssetDecimals)); + if (!Number.isFinite(totalPoolToken) || totalPoolToken <= 0) { + return null; + } + + const projectedApy = plan.projectedWeightedApy; + if (!Number.isFinite(projectedApy)) { + return null; + } + + const totalPoolUsd = totalPoolToken * pricedLoanAssetUsd; + const estimatedDailyEarningsUsd = (totalPoolUsd * projectedApy) / DAYS_PER_YEAR; + if (!Number.isFinite(estimatedDailyEarningsUsd)) { + return null; + } + + return estimatedDailyEarningsUsd; +} + export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartRebalancePlan | null, onSuccess?: () => void) => { const { address: account } = useConnection(); const { batchAddUserMarkets } = useUserMarketsCache(account); @@ -125,6 +159,21 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR return feeBreakdown.totalFee; }, [feeBreakdown.totalFee]); + const feeUsdValue = useMemo( + () => computeAssetUsdValue(feeBreakdown.totalFee, groupedPosition.loanAssetDecimals, feeBreakdown.assetPriceUsd), + [feeBreakdown.assetPriceUsd, feeBreakdown.totalFee, groupedPosition.loanAssetDecimals], + ); + + const isFeeCapped = useMemo( + () => feeBreakdown.totalFee > 0n && feeBreakdown.uncappedTotalFee > feeBreakdown.totalFee, + [feeBreakdown.totalFee, feeBreakdown.uncappedTotalFee], + ); + + const estimatedDailyEarningsUsd = useMemo( + () => computeEstimatedDailyEarningsUsd(plan, groupedPosition.loanAssetDecimals, pricedLoanAssetUsd), + [groupedPosition.loanAssetDecimals, plan, pricedLoanAssetUsd], + ); + const transferAmountEstimate = useMemo(() => { if (!plan) return 0n; @@ -318,6 +367,9 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR isProcessing: execution.isProcessing, totalMoved, feeAmount, + feeUsdValue, + isFeeCapped, + estimatedDailyEarningsUsd, transaction: execution.transaction, dismiss: execution.dismiss, currentStep: execution.currentStep as RebalanceExecutionStepType | null, diff --git a/src/modals/leverage/components/add-collateral-and-leverage.tsx b/src/modals/leverage/components/add-collateral-and-leverage.tsx index 4e32a869..4e80d8f7 100644 --- a/src/modals/leverage/components/add-collateral-and-leverage.tsx +++ b/src/modals/leverage/components/add-collateral-and-leverage.tsx @@ -35,6 +35,7 @@ import { SlippageInlineEditor } from '@/features/swap/components/SlippageInlineE import { DEFAULT_SLIPPAGE_PERCENT, slippagePercentToBps } from '@/features/swap/constants'; import { formatSwapRatePreview } from '@/features/swap/utils/quote-preview'; import { getLeverageFee } from '@/config/fees'; +import { computeAssetUsdValue, formatUsdValueDisplay } from '@/utils/assetDisplay'; import { formatBalance } from '@/utils/balance'; import { previewMarketState } from '@/utils/morpho'; import { convertAprToApy, toApyFromDisplayRate, toDisplayRateFromApy } from '@/utils/rateMath'; @@ -53,6 +54,7 @@ type AddCollateralAndLeverageProps = { const LEVERAGE_SAFE_LTV_BUFFER_BPS = 100n; // keep a 1% buffer below liquidation LTV const TARGET_INPUT_DEBOUNCE_MS = 300; +const INLINE_VALUE_TOOLTIP_CLASS_NAME = 'px-4 py-3 text-xs'; export function AddCollateralAndLeverage({ market, @@ -331,22 +333,14 @@ export function AddCollateralAndLeverage({ if (!isLeverageFeeReady || leverageTransferFee == null) return null; return formatTokenAmountPreview(leverageTransferFee, market.collateralAsset.decimals); }, [isLeverageFeeReady, leverageTransferFee, market.collateralAsset.decimals]); - const leverageFeeUsdPreview = useMemo(() => { + const leverageFeeUsdValue = useMemo(() => { if (!isLeverageFeeReady || leverageTransferFee == null || collateralAssetPriceUsd == null) return null; - - const feeAmount = Number(formatUnits(leverageTransferFee, market.collateralAsset.decimals)); - if (!Number.isFinite(feeAmount) || feeAmount < 0) return null; - - const feeUsdValue = feeAmount * collateralAssetPriceUsd; - if (!Number.isFinite(feeUsdValue) || feeUsdValue < 0) return null; - - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 3, - }).format(feeUsdValue); + return computeAssetUsdValue(leverageTransferFee, market.collateralAsset.decimals, collateralAssetPriceUsd); }, [isLeverageFeeReady, leverageTransferFee, collateralAssetPriceUsd, market.collateralAsset.decimals]); + const leverageFeeUsdDisplay = useMemo( + () => (leverageFeeUsdValue == null ? null : formatUsdValueDisplay(leverageFeeUsdValue)), + [leverageFeeUsdValue], + ); const swapCollateralOutPreview = useMemo( () => formatTokenAmountPreview(quote.flashLegCollateralTokenAmount, market.collateralAsset.decimals), [quote.flashLegCollateralTokenAmount, market.collateralAsset.decimals], @@ -759,10 +753,25 @@ export function AddCollateralAndLeverage({ /> - {leverageFeePreview.full}}> + {leverageFeePreview.compact} - {leverageFeeUsdPreview != null && ({leverageFeeUsdPreview})} + {leverageFeeUsdDisplay != null && + (leverageFeeUsdDisplay.showExactTooltip ? ( + + + {leverageFeeUsdDisplay.display} + + + ) : ( + {leverageFeeUsdDisplay.display} + ))} 0 && usdValue < USD_TINY_DISPLAY_THRESHOLD) { + return { + display: '< $0.01', + exact, + showExactTooltip: true, + }; + } + + const display = formatUsd(usdValue, usdValue < 1 ? 4 : 2); + return { + display, + exact, + showExactTooltip: display !== exact, + }; +} From df85464bb2dbf1a15e1b67eaaf7818d3f5a5e17d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 10 Mar 2026 14:19:41 +0800 Subject: [PATCH 2/2] chore: fix oracle metadata usage --- AGENTS.md | 4 +- .../components/rebalance/rebalance-modal.tsx | 56 ++++++++------- src/hooks/useOracleMetadata.ts | 18 ++++- src/hooks/useSmartRebalance.ts | 70 +++++++++++++------ src/utils/assetDisplay.ts | 6 +- src/utils/marketFilters.ts | 2 +- src/utils/oracle.ts | 10 +-- 7 files changed, 108 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e1a0a985..43e8d32d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,7 +138,7 @@ When touching transaction and position flows, validation MUST include all releva 3. **Rate display consistency**: use shared APY/APR display primitives (for example `src/components/shared/rate-formatted.tsx`, `src/features/positions/components/preview/apy-preview.tsx`, and shared rate-label logic) instead of per-component conversion/label logic. 4. **Post-action rate preview parity**: transaction modals that change position yield/rates must show current -> projected post-action APY/APR, and preview mode (APR vs APY) must match the global setting used by execution summaries. 5. **Bigint unit discipline**: keep on-chain amounts as `bigint` for all calculations; only convert at boundaries with `parseUnits` (input) and `formatUnits` or shared token-amount formatters (display/serialization). -6. **Computation-backed previews**: risk, rate, and amount previews must come from executable quote/oracle/conversion inputs and match tx-builder inputs. +6. **Computation-backed previews**: risk, rate, amount, and fee previews must come from executable quote/oracle/conversion inputs, match tx-builder inputs, and fail closed when required USD pricing for fee caps is unavailable. 7. **Historical rate weighting integrity**: grouped/portfolio realized APY/APR must be weighted by capital-time exposure (for example average capital × exposure time), never by simple averages or balance-only weights that ignore holding duration. 8. **Transaction tracking progress integrity**: use `useTransactionTracking` as the progress-bar/stepper chokepoint, define explicit ordered steps per flow, and call `tracking.update(...)` only when advancing to a strictly later step (never backwards or out of order). 9. **Post-transaction state hygiene**: on success, reset transient draft state and trigger required refetches with bounded/reactive dependencies (no loops or stale closures). @@ -154,7 +154,7 @@ When touching transaction and position flows, validation MUST include all releva 19. **APR/APY unit homogeneity**: in any reward/carry/net-rate calculation, normalize every term (base rate, each reward component, aggregates, and displayed subtotals) to the same selected mode before combining, so displayed formulas remain numerically consistent in both APR and APY modes. 20. **Rebalance objective integrity**: stepwise smart-rebalance planners must evaluate each candidate move by resulting **global weighted objective** (portfolio-level APY/APR), not by local/post-move market APR alone, and must fail safe (no-op) when projected objective is below current objective. 21. **Modal UX integrity**: transaction-modal help/fee tooltips must render above modal layers via shared tooltip z-index chokepoints, and per-flow input mode toggles (for example target LTV vs amount) must persist through shared settings across modal reopen. -22. **Chain-scoped identity integrity**: all market/token/route identity checks must be chain-scoped and use canonical identifiers (`chainId + market.uniqueKey` or `chainId + address`), including matching, dedupe keys, routing, and trust/allowlist gates. +22. **Chain-scoped identity integrity**: all market/token/route identity checks must be chain-scoped and use canonical identifiers (`chainId + market.uniqueKey` or `chainId + address`), including matching, dedupe keys, routing, trust/allowlist gates, and shared metadata/cache lookups. 23. **Bundler residual-asset integrity**: any flash-loan transaction path that routes assets through Bundler/adapter balances (Bundler V2, GeneralAdapter, ParaswapAdapter) must end with explicit trailing sweeps of both loan and collateral tokens to the intended recipient across leverage/deleverage and swap/ERC4626 paths, and must keep execute-time slippage bounds consistent with quote-time slippage settings. 24. **Swap execution-field integrity**: for Velora/Paraswap routes, hard preview and execution guards must validate execution-authoritative fields only (trusted target, exact sell amount, min-out / close floor, token identities). Do not block flows on echoed route metadata such as quoted source or quoted destination amounts when calldata checks already enforce the executable bounds. 25. **Deterministic flash-loan asset floors**: when a no-swap ERC4626 redeem/withdraw leg is the source of flash-loan repayment assets, its execute-time minimum asset bound must be at least the flash-loan settlement amount itself; do not apply swap-style slippage floors that allow the callback to under-return assets and fail only at final flash-loan settlement. diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index c13617d1..f9de142f 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -165,6 +165,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, feeAmount: smartFeeAmount, feeUsdValue: smartFeeUsdValue, isFeeCapped: smartFeeIsCapped, + isFeeReady: isSmartFeeReady, estimatedDailyEarningsUsd, } = useSmartRebalance(groupedPosition, smartPlan, handleSmartTxSuccess); @@ -282,7 +283,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, [groupedPosition.loanAssetDecimals, smartTotalMoved], ); const smartFeePreview = useMemo( - () => formatTokenAmountPreview(smartFeeAmount, groupedPosition.loanAssetDecimals), + () => (smartFeeAmount == null ? null : formatTokenAmountPreview(smartFeeAmount, groupedPosition.loanAssetDecimals)), [groupedPosition.loanAssetDecimals, smartFeeAmount], ); const smartFeeUsdDisplay = useMemo(() => (smartFeeUsdValue == null ? null : formatUsdValueDisplay(smartFeeUsdValue)), [smartFeeUsdValue]); @@ -317,12 +318,14 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, label: 'Capital Moved', value: fmtAmount(smartTotalMoved), }); - items.push({ - id: 'fee', - label: SMART_REBALANCE_FEE_LABEL, - value: `${smartFeePreview.compact} ${groupedPosition.loanAssetSymbol}`, - detail: smartFeeSummaryDetail || undefined, - }); + if (smartFeePreview != null) { + items.push({ + id: 'fee', + label: SMART_REBALANCE_FEE_LABEL, + value: `${smartFeePreview.compact} ${groupedPosition.loanAssetSymbol}`, + detail: smartFeeSummaryDetail || undefined, + }); + } } return items; @@ -332,8 +335,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, rateLabel, smartCurrentWeightedRate, estimatedDailyEarningsUsd, - smartFeeAmount, - smartFeePreview.compact, + smartFeePreview, smartFeeSummaryDetail, smartPlan, smartProjectedWeightedRate, @@ -341,8 +343,10 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, smartWeightedRateDiff, ]); - const smartFeePreviewRow = useMemo( - () => ({ + const smartFeePreviewRow = useMemo(() => { + if (smartFeePreview == null) return null; + + return { id: 'fee', label: ( @@ -389,17 +393,15 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, /> ), - }), - [ - groupedPosition.chainId, - groupedPosition.loanAssetAddress, - groupedPosition.loanAssetSymbol, - smartFeeIsCapped, - smartFeePreview.compact, - smartFeePreview.full, - smartFeeUsdDisplay, - ], - ); + }; + }, [ + groupedPosition.chainId, + groupedPosition.loanAssetAddress, + groupedPosition.loanAssetSymbol, + smartFeeIsCapped, + smartFeePreview, + smartFeeUsdDisplay, + ]); const smartRows = useMemo(() => { const selectedMarkets = [...smartSelectedMarketKeys] @@ -476,7 +478,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, return JSON.stringify(sourceEntries) !== JSON.stringify(debouncedEntries); }, [debouncedSmartMaxAllocationBps, smartMaxAllocationBps]); - const smartCanExecute = !isSmartCalculating && !isSmartConstraintsPending && !!smartPlan && smartTotalMoved > 0n; + const smartCanExecute = !isSmartCalculating && !isSmartConstraintsPending && !!smartPlan && smartTotalMoved > 0n && isSmartFeeReady; const handleDeleteSmartMarket = useCallback( (uniqueKey: string) => { @@ -722,8 +724,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }, [executeRebalance, refetch, toast]); const handleExecuteSmartRebalance = useCallback(() => { + if (!smartCanExecute) return; void executeSmartRebalance(smartSummaryItems); - }, [executeSmartRebalance, smartSummaryItems]); + }, [executeSmartRebalance, smartCanExecute, smartSummaryItems]); const refreshActionLoading = isManualRefreshing || isRefetching; @@ -1070,6 +1073,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch,
{smartCalculationError &&
{smartCalculationError}
} + {!isSmartCalculating && smartPlan && smartTotalMoved > 0n && !isSmartFeeReady && ( +
Waiting for loan asset USD price to enforce the smart rebalance fee cap.
+ )} {!isSmartCalculating && constraintViolations.length > 0 && (
Some max-allocation limits could not be fully satisfied due to current market liquidity/capacity. @@ -1079,7 +1085,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, {smartPlan && ( )} diff --git a/src/hooks/useOracleMetadata.ts b/src/hooks/useOracleMetadata.ts index beee2a29..28528aa0 100644 --- a/src/hooks/useOracleMetadata.ts +++ b/src/hooks/useOracleMetadata.ts @@ -101,6 +101,10 @@ export type OracleMetadataMap = Map; const ORACLE_GIST_BASE_URL = process.env.NEXT_PUBLIC_ORACLE_GIST_BASE_URL?.replace(/\/+$/, ''); +export function getOracleMetadataKey(chainId: number, oracleAddress: string): string { + return `${chainId}-${oracleAddress.toLowerCase()}`; +} + /** * Fetch oracle metadata directly from the centralized Gist. */ @@ -166,16 +170,26 @@ export function useOracleMetadata(chainId: SupportedNetworks | number | undefine export function getOracleFromMetadata( metadataRecord: OracleMetadataRecord | OracleMetadataMap | undefined, oracleAddress: string | undefined, + chainId?: number, ): OracleOutput | undefined { if (!metadataRecord || !oracleAddress) return undefined; const key = oracleAddress.toLowerCase(); + const scopedKey = chainId == null ? null : getOracleMetadataKey(chainId, oracleAddress); // Handle both Map and Record if (metadataRecord instanceof Map) { + if (scopedKey) { + const scopedOracle = metadataRecord.get(scopedKey); + if (scopedOracle) return scopedOracle; + } return metadataRecord.get(key); } + if (scopedKey && metadataRecord[scopedKey]) { + return metadataRecord[scopedKey]; + } + return metadataRecord[key]; } @@ -227,8 +241,8 @@ export function useAllOracleMetadata() { const oracles = query.data?.oracles; if (oracles) { for (const oracle of oracles) { - if (oracle?.address) { - record[oracle.address.toLowerCase()] = oracle; + if (oracle?.address && oracle.chainId != null) { + record[getOracleMetadataKey(oracle.chainId, oracle.address)] = oracle; } } } diff --git a/src/hooks/useSmartRebalance.ts b/src/hooks/useSmartRebalance.ts index 43e719af..b9006722 100644 --- a/src/hooks/useSmartRebalance.ts +++ b/src/hooks/useSmartRebalance.ts @@ -23,6 +23,7 @@ type SmartRebalanceFeeBreakdown = { uncappedTotalFee: bigint; assetPriceUsd: number | null; feeByMarket: Map; + isReady: boolean; }; function deriveLoanAssetPriceUsdFromPlan(plan: SmartRebalancePlan, loanAssetDecimals: number): number | null { @@ -47,10 +48,10 @@ function deriveLoanAssetPriceUsdFromPlan(plan: SmartRebalancePlan, loanAssetDeci function computeFeeBreakdown( plan: SmartRebalancePlan | null, loanAssetDecimals: number, - pricedLoanAssetUsd: number | null, + loanAssetPriceUsd: number | null, ): SmartRebalanceFeeBreakdown { if (!plan) { - return { totalFee: 0n, uncappedTotalFee: 0n, assetPriceUsd: null, feeByMarket: new Map() }; + return { totalFee: 0n, uncappedTotalFee: 0n, assetPriceUsd: null, feeByMarket: new Map(), isReady: false }; } const feeByMarket = new Map(); @@ -69,16 +70,24 @@ function computeFeeBreakdown( } if (uncappedTotal === 0n) { - return { totalFee: 0n, uncappedTotalFee: 0n, assetPriceUsd: null, feeByMarket }; + return { totalFee: 0n, uncappedTotalFee: 0n, assetPriceUsd: loanAssetPriceUsd, feeByMarket, isReady: true }; + } + + if (loanAssetPriceUsd == null || !Number.isFinite(loanAssetPriceUsd) || loanAssetPriceUsd <= 0) { + return { + totalFee: 0n, + uncappedTotalFee: uncappedTotal, + assetPriceUsd: null, + feeByMarket: new Map(), + isReady: false, + }; } - const fallbackPriceUsd = deriveLoanAssetPriceUsdFromPlan(plan, loanAssetDecimals); - const effectiveLoanAssetPriceUsd = pricedLoanAssetUsd ?? fallbackPriceUsd; const cappedTotal = getFee({ amount: uncappedTotal, ratePpm: FULL_RATE_PPM, ceilingUsd: REBALANCE_FEE_CEILING_USD, - assetPriceUsd: effectiveLoanAssetPriceUsd, + assetPriceUsd: loanAssetPriceUsd, assetDecimals: loanAssetDecimals, }); @@ -97,17 +106,18 @@ function computeFeeBreakdown( return { totalFee: cappedTotal, uncappedTotalFee: uncappedTotal, - assetPriceUsd: effectiveLoanAssetPriceUsd, + assetPriceUsd: loanAssetPriceUsd, feeByMarket, + isReady: true, }; } function computeEstimatedDailyEarningsUsd( plan: SmartRebalancePlan | null, loanAssetDecimals: number, - pricedLoanAssetUsd: number | null, + loanAssetPriceUsd: number | null, ): number | null { - if (!plan || pricedLoanAssetUsd == null || !Number.isFinite(pricedLoanAssetUsd) || pricedLoanAssetUsd <= 0) { + if (!plan || loanAssetPriceUsd == null || !Number.isFinite(loanAssetPriceUsd) || loanAssetPriceUsd <= 0) { return null; } @@ -121,8 +131,18 @@ function computeEstimatedDailyEarningsUsd( return null; } - const totalPoolUsd = totalPoolToken * pricedLoanAssetUsd; - const estimatedDailyEarningsUsd = (totalPoolUsd * projectedApy) / DAYS_PER_YEAR; + const totalPoolUsd = totalPoolToken * loanAssetPriceUsd; + const compoundedRateBase = 1 + projectedApy; + if (!Number.isFinite(compoundedRateBase) || compoundedRateBase < 0) { + return null; + } + + const dailyRate = compoundedRateBase === 0 ? -1 : compoundedRateBase ** (1 / DAYS_PER_YEAR) - 1; + if (!Number.isFinite(dailyRate)) { + return null; + } + + const estimatedDailyEarningsUsd = totalPoolUsd * dailyRate; if (!Number.isFinite(estimatedDailyEarningsUsd)) { return null; } @@ -149,29 +169,35 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR () => tokenPrices.get(getTokenPriceKey(groupedPosition.loanAssetAddress, groupedPosition.chainId)) ?? null, [groupedPosition.chainId, groupedPosition.loanAssetAddress, tokenPrices], ); + const fallbackLoanAssetPriceUsd = useMemo( + () => (plan ? deriveLoanAssetPriceUsdFromPlan(plan, groupedPosition.loanAssetDecimals) : null), + [groupedPosition.loanAssetDecimals, plan], + ); + const effectiveLoanAssetPriceUsd = pricedLoanAssetUsd ?? fallbackLoanAssetPriceUsd; const feeBreakdown = useMemo( - () => computeFeeBreakdown(plan, groupedPosition.loanAssetDecimals, pricedLoanAssetUsd), - [groupedPosition.loanAssetDecimals, plan, pricedLoanAssetUsd], + () => computeFeeBreakdown(plan, groupedPosition.loanAssetDecimals, effectiveLoanAssetPriceUsd), + [effectiveLoanAssetPriceUsd, groupedPosition.loanAssetDecimals, plan], ); const feeAmount = useMemo(() => { + if (!feeBreakdown.isReady) return null; return feeBreakdown.totalFee; - }, [feeBreakdown.totalFee]); + }, [feeBreakdown.isReady, feeBreakdown.totalFee]); const feeUsdValue = useMemo( - () => computeAssetUsdValue(feeBreakdown.totalFee, groupedPosition.loanAssetDecimals, feeBreakdown.assetPriceUsd), - [feeBreakdown.assetPriceUsd, feeBreakdown.totalFee, groupedPosition.loanAssetDecimals], + () => (feeAmount == null ? null : computeAssetUsdValue(feeAmount, groupedPosition.loanAssetDecimals, feeBreakdown.assetPriceUsd)), + [feeAmount, feeBreakdown.assetPriceUsd, groupedPosition.loanAssetDecimals], ); const isFeeCapped = useMemo( - () => feeBreakdown.totalFee > 0n && feeBreakdown.uncappedTotalFee > feeBreakdown.totalFee, - [feeBreakdown.totalFee, feeBreakdown.uncappedTotalFee], + () => feeBreakdown.isReady && feeBreakdown.totalFee > 0n && feeBreakdown.uncappedTotalFee > feeBreakdown.totalFee, + [feeBreakdown.isReady, feeBreakdown.totalFee, feeBreakdown.uncappedTotalFee], ); const estimatedDailyEarningsUsd = useMemo( - () => computeEstimatedDailyEarningsUsd(plan, groupedPosition.loanAssetDecimals, pricedLoanAssetUsd), - [groupedPosition.loanAssetDecimals, plan, pricedLoanAssetUsd], + () => computeEstimatedDailyEarningsUsd(plan, groupedPosition.loanAssetDecimals, effectiveLoanAssetPriceUsd), + [effectiveLoanAssetPriceUsd, groupedPosition.loanAssetDecimals, plan], ); const transferAmountEstimate = useMemo(() => { @@ -301,7 +327,7 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR const executeSmartRebalance = useCallback( async (summaryItems?: TransactionSummaryItem[]) => { - if (!plan || !account || totalMoved === 0n) { + if (!plan || !account || totalMoved === 0n || !feeBreakdown.isReady) { return false; } @@ -357,6 +383,7 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR groupedPosition.chainId, groupedPosition.loanAsset, groupedPosition.loanAssetAddress, + feeBreakdown.isReady, plan, totalMoved, ], @@ -369,6 +396,7 @@ export const useSmartRebalance = (groupedPosition: GroupedPosition, plan: SmartR feeAmount, feeUsdValue, isFeeCapped, + isFeeReady: feeBreakdown.isReady, estimatedDailyEarningsUsd, transaction: execution.transaction, dismiss: execution.dismiss, diff --git a/src/utils/assetDisplay.ts b/src/utils/assetDisplay.ts index c9e2d43f..921e190e 100644 --- a/src/utils/assetDisplay.ts +++ b/src/utils/assetDisplay.ts @@ -1,6 +1,7 @@ import { formatUnits } from 'viem'; const USD_TINY_DISPLAY_THRESHOLD = 0.01; +const USD_EXACT_TOOLTIP_FRACTION_DIGITS = 10; function formatUsd(value: number, maximumFractionDigits: number): string { return new Intl.NumberFormat('en-US', { @@ -34,16 +35,15 @@ export function formatUsdValueDisplay(usdValue: number): { exact: string; showExactTooltip: boolean; } { - const exact = formatUsd(usdValue, 6); - if (usdValue > 0 && usdValue < USD_TINY_DISPLAY_THRESHOLD) { return { display: '< $0.01', - exact, + exact: formatUsd(usdValue, USD_EXACT_TOOLTIP_FRACTION_DIGITS), showExactTooltip: true, }; } + const exact = formatUsd(usdValue, 6); const display = formatUsd(usdValue, usdValue < 1 ? 4 : 2); return { display, diff --git a/src/utils/marketFilters.ts b/src/utils/marketFilters.ts index 2a807cbf..f11959a8 100644 --- a/src/utils/marketFilters.ts +++ b/src/utils/marketFilters.ts @@ -104,7 +104,7 @@ export const createUnknownOracleFilter = (showUnknownOracle: boolean, oracleMeta const oracleType = getOracleType(market.oracle?.data, market.oracleAddress, market.morphoBlue.chain.id, oracleMetadataMap); if (oracleType === OracleType.Meta) { - const metadata = getOracleFromMetadata(oracleMetadataMap, market.oracleAddress); + const metadata = getOracleFromMetadata(oracleMetadataMap, market.oracleAddress, market.morphoBlue.chain.id); if (metadata?.data && isMetaOracleData(metadata.data)) { const info = parseMetaOracleVendors(metadata.data); return !info.hasUnknown; diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index c9e8a86f..f2a8aa8c 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -263,7 +263,7 @@ export function getOracleType( ) { // Check scanner metadata for oracle type (meta or standard with vault-only) if (metadataMap && oracleAddress) { - const metadata = getOracleFromMetadata(metadataMap, oracleAddress); + const metadata = getOracleFromMetadata(metadataMap, oracleAddress, chainId); if (metadata?.type === 'meta') return OracleType.Meta; if (metadata?.type === 'standard') return OracleType.Standard; } @@ -308,7 +308,9 @@ export function parsePriceFeedVendors( if (!oracleData.baseFeedOne && !oracleData.baseFeedTwo && !oracleData.quoteFeedOne && !oracleData.quoteFeedTwo) { // Check if this is a vault-only oracle (no feeds but has vault conversion) const oracleMetadata = - options?.metadataMap && options.oracleAddress ? getOracleFromMetadata(options.metadataMap, options.oracleAddress) : undefined; + options?.metadataMap && options.oracleAddress + ? getOracleFromMetadata(options.metadataMap, options.oracleAddress, chainId) + : undefined; const oracleMetadataData = oracleMetadata?.data && !isMetaOracleData(oracleMetadata.data) ? oracleMetadata.data : undefined; const hasVault = oracleMetadataData?.baseVault || oracleMetadataData?.quoteVault; @@ -343,7 +345,7 @@ export function parsePriceFeedVendors( // Try to get enriched metadata for this oracle const oracleMetadata = - options?.metadataMap && options.oracleAddress ? getOracleFromMetadata(options.metadataMap, options.oracleAddress) : undefined; + options?.metadataMap && options.oracleAddress ? getOracleFromMetadata(options.metadataMap, options.oracleAddress, chainId) : undefined; const oracleMetadataData = oracleMetadata?.data && !isMetaOracleData(oracleMetadata.data) ? oracleMetadata.data : undefined; for (const feed of feeds) { @@ -571,7 +573,7 @@ export function checkFeedsPath( // Get metadata for feed path resolution const oracleMetadata = - options?.metadataMap && options?.oracleAddress ? getOracleFromMetadata(options.metadataMap, options.oracleAddress) : undefined; + options?.metadataMap && options?.oracleAddress ? getOracleFromMetadata(options.metadataMap, options.oracleAddress, chainId) : undefined; const oracleMetadataData = oracleMetadata?.data && !isMetaOracleData(oracleMetadata.data) ? oracleMetadata.data : undefined; const feedPaths: FeedPathEntry[] = [