From c11907129708c0ee1a36c99605cd871b4c047689 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 2 Mar 2026 23:34:18 +0800 Subject: [PATCH 1/2] fix: apy jump --- AGENTS.md | 1 + src/hooks/useTransactionWithToast.tsx | 22 +- src/hooks/useUserPositionsSummaryData.ts | 15 +- src/utils/user-transaction-history-cache.ts | 290 ++++++++++++++++++++ 4 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 src/utils/user-transaction-history-cache.ts diff --git a/AGENTS.md b/AGENTS.md index d6463f31..1b6b1a64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,7 @@ When touching transaction and position flows, validation MUST include all releva 20. **Share-based full-exit withdrawals**: when a rebalance target leaves only dust in a source market, tx builders must switch to share-based `morphoWithdraw` (full shares burn with expected-assets guard) instead of asset-amount withdraws, so "empty market" intent cannot strand residual dust due rounding. 21. **Preview stability during quote refresh**: transaction-critical risk previews (LTV text, warning banners, risk bars, submit gating hints) must not be driven by transient placeholder/intermediate quote values while quote/route resolution is loading or refetching; display the last settled preview state (or a neutral loading state) until fresh executable quote data is available. 22. **Bigint-safe input echo formatting**: transaction-critical amount inputs must never round-trip through JavaScript `Number` when syncing bigint state back to text fields; use exact bigint/string unit formatters so typed values (for example `100000`) never mutate into precision-drifted decimals. +23. **Indexer-lag transaction history bridging**: when earnings/APY depends on recent supply/withdraw history, confirmed on-chain receipts must be parsed into a short-lived local transaction cache (scoped by canonical user address + chain, deduped by tx hash + log index, TTL-bounded), merged into reads while indexers lag, and automatically removed as soon as the API returns the same tx hash to prevent double counting. ### REQUIRED: Regression Rule Capture diff --git a/src/hooks/useTransactionWithToast.tsx b/src/hooks/useTransactionWithToast.tsx index 66e10865..e959927b 100644 --- a/src/hooks/useTransactionWithToast.tsx +++ b/src/hooks/useTransactionWithToast.tsx @@ -4,6 +4,7 @@ import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi'; import { StyledToast, TransactionToast } from '@/components/ui/styled-toast'; import { reportHandledError } from '@/utils/sentry'; import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; +import { cacheUserTransactionHistoryFromReceipt } from '@/utils/user-transaction-history-cache'; import { getExplorerTxURL } from '../utils/external'; import type { SupportedNetworks } from '../utils/networks'; @@ -39,15 +40,17 @@ export function useTransactionWithToast({ }: UseTransactionWithToastProps) { const { data: hash, mutate: sendTransaction, error: txError, mutateAsync: sendTransactionAsync } = useSendTransaction(); const reportedErrorKeyRef = useRef(null); + const handledConfirmationHashRef = useRef(null); const { + data: receipt, isLoading: isConfirming, isSuccess: isConfirmed, isError, error: receiptError, } = useWaitForTransactionReceipt({ hash, - chainId: chainId, + chainId, confirmations: 0, }); @@ -86,7 +89,12 @@ export function useTransactionWithToast({ }, [isConfirming, pendingText, pendingDescription, toastId, onClick, hash]); useEffect(() => { - if (isConfirmed) { + if (isConfirmed && hash) { + if (handledConfirmationHashRef.current === hash) { + return; + } + handledConfirmationHashRef.current = hash; + toast.update(toastId, { render: ( + mergeUserTransactionsWithRecentCache({ + userAddress: user, + chainIds: uniqueChainIds, + apiTransactions: txData?.items ?? [], + }), + [user, uniqueChainIds, txData?.items], + ); + const { data: allSnapshots, isLoading: isLoadingSnapshots, @@ -74,7 +85,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP const positionsWithEarnings = usePositionsWithEarnings( positions ?? [], - txData?.items ?? [], + mergedTransactions, allSnapshots ?? {}, actualBlockData ?? {}, endTimestamp, @@ -130,7 +141,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP refetch, loadingStates, actualBlockData: actualBlockData ?? {}, - transactions: txData?.items ?? [], + transactions: mergedTransactions, snapshotsByChain: allSnapshots ?? {}, }; }; diff --git a/src/utils/user-transaction-history-cache.ts b/src/utils/user-transaction-history-cache.ts new file mode 100644 index 00000000..ee1d0dbc --- /dev/null +++ b/src/utils/user-transaction-history-cache.ts @@ -0,0 +1,290 @@ +import { type Address, type Hex, decodeEventLog } from 'viem'; +import morphoAbi from '@/abis/morpho'; +import { getMorphoAddress } from '@/utils/morpho'; +import type { SupportedNetworks } from '@/utils/networks'; +import { type UserTransaction, UserTxTypes } from '@/utils/types'; + +const CACHE_KEY = 'monarch_cache_userTransactionHistory_v1'; +const CACHE_TTL_MS = 5 * 60 * 1000; +const LOG_PREFIX = '[tx-history-bridge]'; +const IS_DEV = process.env.NODE_ENV !== 'production'; + +type ReceiptLogLike = { + address: Address; + data: Hex; + topics: readonly Hex[]; + logIndex?: number | null; +}; + +type ReceiptLike = { + logs?: readonly ReceiptLogLike[]; +}; + +type CachedUserTransactionEntry = { + chainId: number; + userAddress: Address; + expiresAt: number; + logIndex: number; + tx: UserTransaction; +}; + +const normalizeAddress = (address: string): Address => address.toLowerCase() as Address; + +const logInfo = (message: string, meta?: Record): void => { + if (!IS_DEV) return; + if (meta) { + console.info(LOG_PREFIX, message, meta); + return; + } + console.info(LOG_PREFIX, message); +}; + +const getTransactionDedupKey = (transaction: UserTransaction): string => { + const marketKey = transaction.data?.market?.uniqueKey?.toLowerCase() ?? ''; + const assets = transaction.data?.assets ?? '0'; + const shares = transaction.data?.shares ?? '0'; + return `${transaction.hash.toLowerCase()}:${transaction.type}:${marketKey}:${assets}:${shares}`; +}; + +const getCacheEntryDedupKey = (entry: CachedUserTransactionEntry): string => + `${entry.chainId}:${entry.userAddress}:${entry.tx.hash.toLowerCase()}:${entry.logIndex}`; + +const isCacheEntry = (value: unknown): value is CachedUserTransactionEntry => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + + return ( + typeof candidate.chainId === 'number' && + typeof candidate.userAddress === 'string' && + typeof candidate.expiresAt === 'number' && + typeof candidate.logIndex === 'number' && + !!candidate.tx && + typeof candidate.tx.hash === 'string' && + typeof candidate.tx.timestamp === 'number' && + typeof candidate.tx.type === 'string' && + !!candidate.tx.data && + typeof candidate.tx.data.assets === 'string' && + typeof candidate.tx.data.shares === 'string' && + !!candidate.tx.data.market && + typeof candidate.tx.data.market.uniqueKey === 'string' + ); +}; + +const readCacheEntries = (): CachedUserTransactionEntry[] => { + if (typeof window === 'undefined') return []; + + try { + const raw = window.localStorage.getItem(CACHE_KEY); + if (!raw) return []; + + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + + return parsed.filter(isCacheEntry); + } catch { + return []; + } +}; + +const writeCacheEntries = (entries: CachedUserTransactionEntry[]): void => { + if (typeof window === 'undefined') return; + + try { + window.localStorage.setItem(CACHE_KEY, JSON.stringify(entries)); + } catch { + // Ignore localStorage write errors (e.g. private mode / quota) + } +}; + +const readAndPruneCacheEntries = (): CachedUserTransactionEntry[] => { + const entries = readCacheEntries(); + if (entries.length === 0) return entries; + + const now = Date.now(); + const activeEntries = entries.filter((entry) => entry.expiresAt > now); + if (activeEntries.length !== entries.length) { + writeCacheEntries(activeEntries); + } + return activeEntries; +}; + +export function cacheUserTransactionHistoryFromReceipt({ + receipt, + txHash, + chainId, +}: { + receipt: ReceiptLike | null | undefined; + txHash: string; + chainId: number; +}): void { + if (typeof window === 'undefined' || !receipt?.logs?.length || !chainId || !txHash) { + return; + } + + let morphoAddress: string; + try { + morphoAddress = getMorphoAddress(chainId as SupportedNetworks).toLowerCase(); + } catch { + return; + } + + const timestamp = Math.floor(Date.now() / 1000); + const expiresAt = Date.now() + CACHE_TTL_MS; + const parsedEntries: CachedUserTransactionEntry[] = []; + + for (let index = 0; index < receipt.logs.length; index += 1) { + const log = receipt.logs[index]; + if (log.address.toLowerCase() !== morphoAddress) continue; + if (log.topics.length === 0) continue; + + try { + const [signature, ...topicsRest] = log.topics; + const decoded = decodeEventLog({ + abi: morphoAbi, + data: log.data, + topics: [signature, ...topicsRest], + }); + + if (decoded.eventName !== 'Supply' && decoded.eventName !== 'Withdraw') { + continue; + } + + const { id, onBehalf, assets } = decoded.args; + const shares = decoded.args.shares; + if (typeof id !== 'string' || typeof onBehalf !== 'string' || typeof assets !== 'bigint') { + continue; + } + + const txType = decoded.eventName === 'Supply' ? UserTxTypes.MarketSupply : UserTxTypes.MarketWithdraw; + + parsedEntries.push({ + chainId, + userAddress: normalizeAddress(onBehalf), + expiresAt, + logIndex: log.logIndex ?? index, + tx: { + hash: txHash, + timestamp, + type: txType, + data: { + __typename: txType, + assets: assets.toString(), + shares: (typeof shares === 'bigint' ? shares : 0n).toString(), + market: { + uniqueKey: id.toLowerCase(), + }, + }, + }, + }); + } catch { + // Not a Morpho event we care about. + } + } + + if (parsedEntries.length === 0) return; + + const activeEntries = readAndPruneCacheEntries(); + const existingKeys = new Set(activeEntries.map(getCacheEntryDedupKey)); + const nextEntries = [...activeEntries]; + + for (const entry of parsedEntries) { + const key = getCacheEntryDedupKey(entry); + if (existingKeys.has(key)) { + logInfo('Skipped duplicate bridge event', { + txHash: entry.tx.hash, + chainId: entry.chainId, + logIndex: entry.logIndex, + type: entry.tx.type, + marketUniqueKey: entry.tx.data.market.uniqueKey, + userAddress: entry.userAddress, + }); + continue; + } + existingKeys.add(key); + nextEntries.push(entry); + logInfo('Added receipt event to temporary history', { + txHash: entry.tx.hash, + chainId: entry.chainId, + logIndex: entry.logIndex, + type: entry.tx.type, + marketUniqueKey: entry.tx.data.market.uniqueKey, + assets: entry.tx.data.assets, + shares: entry.tx.data.shares, + userAddress: entry.userAddress, + expiresAt: entry.expiresAt, + }); + } + + writeCacheEntries(nextEntries); +} + +export function mergeUserTransactionsWithRecentCache({ + userAddress, + chainIds, + apiTransactions, +}: { + userAddress: string | undefined; + chainIds: number[]; + apiTransactions: UserTransaction[]; +}): UserTransaction[] { + if (typeof window === 'undefined' || !userAddress || chainIds.length === 0) { + return apiTransactions; + } + + const normalizedUser = normalizeAddress(userAddress); + const chainIdSet = new Set(chainIds); + const apiHashes = new Set(apiTransactions.map((tx) => tx.hash.toLowerCase())); + + const activeEntries = readAndPruneCacheEntries(); + if (activeEntries.length === 0) { + return apiTransactions; + } + + const removedForApiCatchup: CachedUserTransactionEntry[] = []; + const cleanedEntries = activeEntries.filter((entry) => { + const isRelevantEntry = entry.userAddress === normalizedUser && chainIdSet.has(entry.chainId); + if (!isRelevantEntry) return true; + const shouldKeep = !apiHashes.has(entry.tx.hash.toLowerCase()); + if (!shouldKeep) { + removedForApiCatchup.push(entry); + } + return shouldKeep; + }); + + if (cleanedEntries.length !== activeEntries.length) { + writeCacheEntries(cleanedEntries); + logInfo('Removed temporary history entries now present in API history', { + userAddress: normalizedUser, + removedCount: removedForApiCatchup.length, + removedTxHashes: [...new Set(removedForApiCatchup.map((entry) => entry.tx.hash.toLowerCase()))], + }); + } + + const cachedTransactions = cleanedEntries + .filter((entry) => entry.userAddress === normalizedUser && chainIdSet.has(entry.chainId)) + .map((entry) => entry.tx); + + if (cachedTransactions.length === 0) { + return apiTransactions; + } + + logInfo('Merged temporary history entries into user transaction stream', { + userAddress: normalizedUser, + chainIds: [...chainIdSet], + apiCount: apiTransactions.length, + cachedCount: cachedTransactions.length, + }); + + const deduped: UserTransaction[] = []; + const seen = new Set(); + + for (const tx of [...apiTransactions, ...cachedTransactions]) { + const key = getTransactionDedupKey(tx); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(tx); + } + + deduped.sort((a, b) => b.timestamp - a.timestamp); + return deduped; +} From 13dd84028ca325ade55adc9eb8ae0ea0ba11e46b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 3 Mar 2026 00:21:55 +0800 Subject: [PATCH 2/2] chore: review fixes --- src/hooks/useUserPositionsSummaryData.ts | 12 +- src/hooks/useWrapLegacyMorpho.ts | 1 + src/utils/user-transaction-history-cache.ts | 117 +++++++++----------- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index fe7c3e3f..c975cccc 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; import type { SupportedNetworks } from '@/utils/networks'; @@ -8,7 +8,7 @@ import { useBlockTimestamps } from './queries/useBlockTimestamps'; import { usePositionSnapshots } from './queries/usePositionSnapshots'; import { useUserTransactionsQuery } from './queries/useUserTransactionsQuery'; import { usePositionsWithEarnings, getPeriodTimestamp } from './usePositionsWithEarnings'; -import { mergeUserTransactionsWithRecentCache } from '@/utils/user-transaction-history-cache'; +import { mergeUserTransactionsWithRecentCache, reconcileUserTransactionHistoryCache } from '@/utils/user-transaction-history-cache'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; export type { EarningsPeriod } from '@/stores/usePositionsFilters'; @@ -73,6 +73,14 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP [user, uniqueChainIds, txData?.items], ); + useEffect(() => { + reconcileUserTransactionHistoryCache({ + userAddress: user, + chainIds: uniqueChainIds, + apiTransactions: txData?.items ?? [], + }); + }, [user, uniqueChainIds, txData?.items]); + const { data: allSnapshots, isLoading: isLoadingSnapshots, diff --git a/src/hooks/useWrapLegacyMorpho.ts b/src/hooks/useWrapLegacyMorpho.ts index 4394cb5b..642d8920 100644 --- a/src/hooks/useWrapLegacyMorpho.ts +++ b/src/hooks/useWrapLegacyMorpho.ts @@ -36,6 +36,7 @@ export function useWrapLegacyMorpho(amount: bigint, onSuccess?: () => void) { pendingText: 'Wrapping MORPHO...', successText: 'Successfully wrapped MORPHO tokens!', errorText: 'Failed to wrap MORPHO tokens', + chainId: SupportedNetworks.Mainnet, onSuccess: () => { tracking.complete(); onSuccess?.(); diff --git a/src/utils/user-transaction-history-cache.ts b/src/utils/user-transaction-history-cache.ts index ee1d0dbc..8147898a 100644 --- a/src/utils/user-transaction-history-cache.ts +++ b/src/utils/user-transaction-history-cache.ts @@ -30,15 +30,6 @@ type CachedUserTransactionEntry = { const normalizeAddress = (address: string): Address => address.toLowerCase() as Address; -const logInfo = (message: string, meta?: Record): void => { - if (!IS_DEV) return; - if (meta) { - console.info(LOG_PREFIX, message, meta); - return; - } - console.info(LOG_PREFIX, message); -}; - const getTransactionDedupKey = (transaction: UserTransaction): string => { const marketKey = transaction.data?.market?.uniqueKey?.toLowerCase() ?? ''; const assets = transaction.data?.assets ?? '0'; @@ -96,6 +87,11 @@ const writeCacheEntries = (entries: CachedUserTransactionEntry[]): void => { } }; +const getActiveCacheEntries = (): CachedUserTransactionEntry[] => { + const now = Date.now(); + return readCacheEntries().filter((entry) => entry.expiresAt > now); +}; + const readAndPruneCacheEntries = (): CachedUserTransactionEntry[] => { const entries = readCacheEntries(); if (entries.length === 0) return entries; @@ -132,8 +128,7 @@ export function cacheUserTransactionHistoryFromReceipt({ const expiresAt = Date.now() + CACHE_TTL_MS; const parsedEntries: CachedUserTransactionEntry[] = []; - for (let index = 0; index < receipt.logs.length; index += 1) { - const log = receipt.logs[index]; + for (const [index, log] of receipt.logs.entries()) { if (log.address.toLowerCase() !== morphoAddress) continue; if (log.topics.length === 0) continue; @@ -186,36 +181,21 @@ export function cacheUserTransactionHistoryFromReceipt({ const activeEntries = readAndPruneCacheEntries(); const existingKeys = new Set(activeEntries.map(getCacheEntryDedupKey)); const nextEntries = [...activeEntries]; + let addedCount = 0; for (const entry of parsedEntries) { const key = getCacheEntryDedupKey(entry); - if (existingKeys.has(key)) { - logInfo('Skipped duplicate bridge event', { - txHash: entry.tx.hash, - chainId: entry.chainId, - logIndex: entry.logIndex, - type: entry.tx.type, - marketUniqueKey: entry.tx.data.market.uniqueKey, - userAddress: entry.userAddress, - }); - continue; - } + if (existingKeys.has(key)) continue; existingKeys.add(key); nextEntries.push(entry); - logInfo('Added receipt event to temporary history', { - txHash: entry.tx.hash, - chainId: entry.chainId, - logIndex: entry.logIndex, - type: entry.tx.type, - marketUniqueKey: entry.tx.data.market.uniqueKey, - assets: entry.tx.data.assets, - shares: entry.tx.data.shares, - userAddress: entry.userAddress, - expiresAt: entry.expiresAt, - }); + addedCount += 1; } writeCacheEntries(nextEntries); + + if (IS_DEV && addedCount > 0) { + console.log(LOG_PREFIX, `combining ${addedCount} events from tx ${txHash}`); + } } export function mergeUserTransactionsWithRecentCache({ @@ -235,46 +215,24 @@ export function mergeUserTransactionsWithRecentCache({ const chainIdSet = new Set(chainIds); const apiHashes = new Set(apiTransactions.map((tx) => tx.hash.toLowerCase())); - const activeEntries = readAndPruneCacheEntries(); + const activeEntries = getActiveCacheEntries(); if (activeEntries.length === 0) { return apiTransactions; } - const removedForApiCatchup: CachedUserTransactionEntry[] = []; - const cleanedEntries = activeEntries.filter((entry) => { - const isRelevantEntry = entry.userAddress === normalizedUser && chainIdSet.has(entry.chainId); - if (!isRelevantEntry) return true; - const shouldKeep = !apiHashes.has(entry.tx.hash.toLowerCase()); - if (!shouldKeep) { - removedForApiCatchup.push(entry); - } - return shouldKeep; - }); - - if (cleanedEntries.length !== activeEntries.length) { - writeCacheEntries(cleanedEntries); - logInfo('Removed temporary history entries now present in API history', { - userAddress: normalizedUser, - removedCount: removedForApiCatchup.length, - removedTxHashes: [...new Set(removedForApiCatchup.map((entry) => entry.tx.hash.toLowerCase()))], - }); - } - - const cachedTransactions = cleanedEntries - .filter((entry) => entry.userAddress === normalizedUser && chainIdSet.has(entry.chainId)) + const cachedTransactions = activeEntries + .filter((entry) => { + if (entry.userAddress !== normalizedUser || !chainIdSet.has(entry.chainId)) { + return false; + } + return !apiHashes.has(entry.tx.hash.toLowerCase()); + }) .map((entry) => entry.tx); if (cachedTransactions.length === 0) { return apiTransactions; } - logInfo('Merged temporary history entries into user transaction stream', { - userAddress: normalizedUser, - chainIds: [...chainIdSet], - apiCount: apiTransactions.length, - cachedCount: cachedTransactions.length, - }); - const deduped: UserTransaction[] = []; const seen = new Set(); @@ -288,3 +246,36 @@ export function mergeUserTransactionsWithRecentCache({ deduped.sort((a, b) => b.timestamp - a.timestamp); return deduped; } + +export function reconcileUserTransactionHistoryCache({ + userAddress, + chainIds, + apiTransactions, +}: { + userAddress: string | undefined; + chainIds: number[]; + apiTransactions: UserTransaction[]; +}): void { + if (typeof window === 'undefined' || !userAddress || chainIds.length === 0) { + return; + } + + const normalizedUser = normalizeAddress(userAddress); + const chainIdSet = new Set(chainIds); + const apiHashes = new Set(apiTransactions.map((tx) => tx.hash.toLowerCase())); + + const activeEntries = readAndPruneCacheEntries(); + if (activeEntries.length === 0) return; + + const cleanedEntries = activeEntries.filter((entry) => { + const isRelevantEntry = entry.userAddress === normalizedUser && chainIdSet.has(entry.chainId); + if (!isRelevantEntry) return true; + + const shouldKeep = !apiHashes.has(entry.tx.hash.toLowerCase()); + return shouldKeep; + }); + + if (cleanedEntries.length === activeEntries.length) return; + + writeCacheEntries(cleanedEntries); +}