From 18780f3b7d9f3f59800f8b52bcb624e2217a72cf Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:17:17 +0100 Subject: [PATCH 01/81] feat: add vault activity timeline --- .../activity/ActivityCategoryFilters.vue | 47 ++ .../entities/activity/ActivityEventRow.vue | 191 ++++++++ components/entities/activity/ActivityFeed.vue | 208 +++++++++ .../overview/SecuritizeVaultOverview.vue | 6 + .../entities/vault/overview/VaultOverview.vue | 6 + .../VaultOverviewAccordionSection.vue | 20 +- .../overview/VaultOverviewBlockActivity.vue | 95 ++++ .../vault/overview/earn/VaultOverviewEarn.vue | 6 + composables/useActivityAvailability.ts | 132 ++++++ composables/useActivityFeed.ts | 273 +++++++++++ server/api/internal/v3/[...path].ts | 4 +- server/utils/v3-proxy-backoff.ts | 42 +- server/utils/v3-proxy.ts | 43 +- .../useActivityAvailability.test.ts | 156 +++++++ tests/composables/useActivityFeed.test.ts | 434 ++++++++++++++++++ tests/server/v3-proxy-backoff.test.ts | 75 +++ tests/server/v3-proxy-route.test.ts | 35 ++ tests/server/v3-proxy.test.ts | 49 ++ tests/utils/activity-display.test.ts | 139 ++++++ utils/activity-display.ts | 292 ++++++++++++ utils/sdk-query-cache.ts | 22 +- utils/sdk-query-policy.ts | 6 + 22 files changed, 2272 insertions(+), 9 deletions(-) create mode 100644 components/entities/activity/ActivityCategoryFilters.vue create mode 100644 components/entities/activity/ActivityEventRow.vue create mode 100644 components/entities/activity/ActivityFeed.vue create mode 100644 components/entities/vault/overview/VaultOverviewBlockActivity.vue create mode 100644 composables/useActivityAvailability.ts create mode 100644 composables/useActivityFeed.ts create mode 100644 tests/composables/useActivityAvailability.test.ts create mode 100644 tests/composables/useActivityFeed.test.ts create mode 100644 tests/server/v3-proxy-backoff.test.ts create mode 100644 tests/utils/activity-display.test.ts create mode 100644 utils/activity-display.ts diff --git a/components/entities/activity/ActivityCategoryFilters.vue b/components/entities/activity/ActivityCategoryFilters.vue new file mode 100644 index 000000000..2949ae689 --- /dev/null +++ b/components/entities/activity/ActivityCategoryFilters.vue @@ -0,0 +1,47 @@ + + + diff --git a/components/entities/activity/ActivityEventRow.vue b/components/entities/activity/ActivityEventRow.vue new file mode 100644 index 000000000..129f828ae --- /dev/null +++ b/components/entities/activity/ActivityEventRow.vue @@ -0,0 +1,191 @@ + + + diff --git a/components/entities/activity/ActivityFeed.vue b/components/entities/activity/ActivityFeed.vue new file mode 100644 index 000000000..4c9c26a55 --- /dev/null +++ b/components/entities/activity/ActivityFeed.vue @@ -0,0 +1,208 @@ + + + diff --git a/components/entities/vault/overview/SecuritizeVaultOverview.vue b/components/entities/vault/overview/SecuritizeVaultOverview.vue index 9d6ca46c4..5febee351 100644 --- a/components/entities/vault/overview/SecuritizeVaultOverview.vue +++ b/components/entities/vault/overview/SecuritizeVaultOverview.vue @@ -277,6 +277,12 @@ const supplyCapPercentageDisplay = computed(() => { + + isVaultCyclicalNote(vault.address)) :default-open="true" /> + + (), { defaultOpen: true, contentClass: 'flex flex-col gap-24', hasActions: true, + keepMounted: false, }) +const emit = defineEmits<{ + 'update:open': [open: boolean] +}>() const isOpen = ref(props.defaultOpen) const panelId = useId() const sectionEl = ref() +const setOpen = (open: boolean) => { + if (isOpen.value === open) return + isOpen.value = open + emit('update:open', open) +} + const expandIfOnlySection = () => { const parentEl = sectionEl.value?.parentElement if (!parentEl) return const sections = Array.from(parentEl.querySelectorAll(':scope > [data-vault-overview-accordion-section]')) if (sections.length === 1) { - isOpen.value = true + setOpen(true) return } @@ -28,12 +39,12 @@ const expandIfOnlySection = () => { section.querySelector('button[aria-expanded="false"]'), ) if (collapsedSections.length === 1 && collapsedSections[0] === sectionEl.value) { - isOpen.value = true + setOpen(true) } } const toggle = () => { - isOpen.value = !isOpen.value + setOpen(!isOpen.value) } onMounted(async () => { @@ -86,7 +97,8 @@ onMounted(async () => {
diff --git a/components/entities/vault/overview/VaultOverviewBlockActivity.vue b/components/entities/vault/overview/VaultOverviewBlockActivity.vue new file mode 100644 index 000000000..3e8aabcb0 --- /dev/null +++ b/components/entities/vault/overview/VaultOverviewBlockActivity.vue @@ -0,0 +1,95 @@ + + + diff --git a/components/entities/vault/overview/earn/VaultOverviewEarn.vue b/components/entities/vault/overview/earn/VaultOverviewEarn.vue index 8f361d8fe..18af563f4 100644 --- a/components/entities/vault/overview/earn/VaultOverviewEarn.vue +++ b/components/entities/vault/overview/earn/VaultOverviewEarn.vue @@ -27,6 +27,12 @@ const { vault } = defineProps<{ vault: EulerEarn, desktopOverview?: boolean }>() :default-open="true" /> + + , + chainId: MaybeRefOrGetter, +) => { + const { isV3EnabledForChain } = useV3ChainGate() + const capabilities = shallowRef() + const isChecking = ref(false) + const isSupported = ref(false) + const scopeSupport = ref() + const reason = ref() + let activeRequestId = 0 + + const resolvedChainId = computed(() => Number(toValue(chainId))) + const scopeKey = computed(() => { + const value = toValue(scope) + return value.kind === 'account' ? 'account' : `vault:${value.vaultType}` + }) + const shouldRender = computed(() => + isSupported.value || reason.value === 'capability-check-failed', + ) + + const checkAvailability = async ({ preserveCapabilityFailure = false } = {}) => { + const requestId = ++activeRequestId + const targetChainId = resolvedChainId.value + const targetScope = toValue(scope) + const keepCapabilityFailure = preserveCapabilityFailure + && reason.value === 'capability-check-failed' + + capabilities.value = undefined + isSupported.value = false + scopeSupport.value = undefined + if (!keepCapabilityFailure) reason.value = undefined + + if (!Number.isSafeInteger(targetChainId) || targetChainId <= 0) { + reason.value = 'invalid-chain' + isChecking.value = false + return + } + if (!isV3EnabledForChain(targetChainId)) { + reason.value = 'v3-disabled' + isChecking.value = false + return + } + + isChecking.value = true + try { + const { getEulerSdkForChain } = useEulerSdk() + const sdk = await getEulerSdkForChain(targetChainId) + if (requestId !== activeRequestId) return + + const activityService = sdk.activityService + const nextCapabilities = activityService.getCapabilities() + capabilities.value = nextCapabilities + if (!nextCapabilities.configured) { + reason.value = nextCapabilities.reason === 'source-not-configured' + ? 'source-not-configured' + : 'v3-disabled' + return + } + + const routeShapeSupported = targetScope.kind === 'account' + ? nextCapabilities.canQueryAccount + : nextCapabilities.requestableVaultTypes.includes(targetScope.vaultType) + if (!routeShapeSupported) { + reason.value = 'unsupported-scope' + return + } + + const nextScopeSupport = activityService.getScopeSupport(targetScope.kind === 'account' + ? { kind: 'account', chainId: targetChainId } + : { kind: 'vault', chainId: targetChainId, vaultType: targetScope.vaultType }) + scopeSupport.value = nextScopeSupport + // `unknown` means the route is requestable and response coverage is the + // authority. Only an explicit unsupported result hides the surface. + isSupported.value = nextScopeSupport !== 'unsupported' + reason.value = isSupported.value ? undefined : 'unsupported-scope' + } + catch { + if (requestId !== activeRequestId) return + reason.value = 'capability-check-failed' + } + finally { + if (requestId === activeRequestId) isChecking.value = false + } + } + + watch([resolvedChainId, scopeKey], () => { + void checkAvailability() + }, { immediate: true }) + + const refreshAvailability = () => checkAvailability({ preserveCapabilityFailure: true }) + + onScopeDispose(() => { + activeRequestId++ + }) + + return { + capabilities, + isChecking, + isSupported, + reason, + shouldRender, + scopeSupport, + refreshAvailability, + } +} diff --git a/composables/useActivityFeed.ts b/composables/useActivityFeed.ts new file mode 100644 index 000000000..f482a8775 --- /dev/null +++ b/composables/useActivityFeed.ts @@ -0,0 +1,273 @@ +import type { + ActivityCategory, + ActivityCoverage, + ActivityEvent, + ActivityEventsMeta, + ActivityVaultType, +} from '@eulerxyz/euler-v2-sdk' +import type { Address } from 'viem' +import { + computed, + onScopeDispose, + ref, + shallowRef, + toValue, + watch, + type MaybeRefOrGetter, +} from 'vue' +import { subscribeToSdkQueryInvalidations } from '~/utils/sdk-query-cache' +import { ACTIVITY_QUERY_STALE_TIME_MS } from '~/utils/sdk-query-policy' + +export type ActivityFeedScope + = | { kind: 'account', owner: Address, chainId: number | readonly number[] } + | { kind: 'vault', vault: Address, chainId: number, vaultType: ActivityVaultType } + +interface UseActivityFeedOptions { + scope: MaybeRefOrGetter + enabled: MaybeRefOrGetter + categories: MaybeRefOrGetter + limit?: number +} + +type ActivityLoadMode = 'initial' | 'refresh' | 'append' + +const normalizedCategories = (categories: readonly ActivityCategory[]): ActivityCategory[] => + [...new Set(categories)].sort() + +const scopeSdkChainId = (scope: ActivityFeedScope): number => + typeof scope.chainId === 'number' ? scope.chainId : (scope.chainId[0] ?? 0) + +export const buildActivityFeedContextKey = ( + scope: ActivityFeedScope, + categories: readonly ActivityCategory[], +): string => { + const categoryKey = normalizedCategories(categories).join(',') || 'all' + if (scope.kind === 'account') { + const chainKey = typeof scope.chainId === 'number' + ? String(scope.chainId) + : [...scope.chainId].sort((left, right) => left - right).join(',') + return `account:${scope.owner.toLowerCase()}:${chainKey}:${categoryKey}` + } + return `vault:${scope.vaultType}:${scope.chainId}:${scope.vault.toLowerCase()}:${categoryKey}` +} + +export const mergeActivityEvents = ( + current: readonly ActivityEvent[], + incoming: readonly ActivityEvent[], +): ActivityEvent[] => { + const seen = new Set() + return [...current, ...incoming].filter((event) => { + if (seen.has(event.id)) return false + seen.add(event.id) + return true + }) +} + +const asError = (error: unknown): Error => + error instanceof Error ? error : new Error(String(error)) + +export const useActivityFeed = ({ + scope, + enabled, + categories, + limit = 25, +}: UseActivityFeedOptions) => { + const events = shallowRef([]) + const meta = shallowRef() + const error = shallowRef() + const loadMoreError = shallowRef() + const hasLoaded = ref(false) + const isLoading = ref(false) + const isRefreshing = ref(false) + const isLoadingMore = ref(false) + let activeRequestId = 0 + let lastHeadLoadedAt: number | undefined + let pendingInvalidationRefresh = false + let pendingResumeRefresh = false + + const contextKey = computed(() => + buildActivityFeedContextKey(toValue(scope), toValue(categories)), + ) + const isEnabled = computed(() => Boolean(toValue(enabled))) + const coverage = computed(() => meta.value?.coverage) + const hasMore = computed(() => Boolean(meta.value?.hasMore && meta.value.nextCursor)) + const isPartial = computed(() => coverage.value?.status === 'partial') + const isSyncing = computed(() => coverage.value?.status === 'syncing') + const isUnsupported = computed(() => coverage.value?.status === 'unsupported') + const hasColdError = computed(() => Boolean(error.value && events.value.length === 0)) + const hasStaleError = computed(() => Boolean(error.value && events.value.length > 0)) + const isEmpty = computed(() => + hasLoaded.value + && !error.value + && !isUnsupported.value + && !isSyncing.value + && events.value.length === 0, + ) + + const resetForContext = () => { + events.value = [] + meta.value = undefined + error.value = undefined + loadMoreError.value = undefined + hasLoaded.value = false + isLoading.value = false + isRefreshing.value = false + isLoadingMore.value = false + lastHeadLoadedAt = undefined + pendingInvalidationRefresh = false + pendingResumeRefresh = false + } + + const isHeadStale = () => + lastHeadLoadedAt !== undefined + && Date.now() - lastHeadLoadedAt >= ACTIVITY_QUERY_STALE_TIME_MS + + const fetchPage = async (mode: ActivityLoadMode) => { + if (!isEnabled.value) return + if (isLoading.value || isRefreshing.value || isLoadingMore.value) return + if (mode === 'append' && !hasMore.value) return + + const requestId = ++activeRequestId + const requestContext = contextKey.value + const requestScope = toValue(scope) + const requestCategories = normalizedCategories(toValue(categories)) + const cursor = mode === 'append' ? meta.value?.nextCursor ?? undefined : undefined + const coldRequest = mode === 'initial' || events.value.length === 0 + if (mode !== 'append') { + pendingInvalidationRefresh = false + pendingResumeRefresh = false + } + + if (mode === 'append') { + isLoadingMore.value = true + loadMoreError.value = undefined + } + else if (coldRequest) { + isLoading.value = true + error.value = undefined + } + else { + isRefreshing.value = true + error.value = undefined + } + + try { + const { getEulerSdkForChain } = useEulerSdk() + const sdk = await getEulerSdkForChain(scopeSdkChainId(requestScope)) + const common = { + ...(requestCategories.length ? { categories: requestCategories } : {}), + ...(cursor ? { cursor } : {}), + limit, + } + const page = requestScope.kind === 'account' + ? await sdk.activityService.fetchAccountActivityEvents({ + owner: requestScope.owner, + chainId: requestScope.chainId, + ...common, + }) + : await sdk.activityService.fetchVaultActivityEvents({ + vault: requestScope.vault, + chainId: requestScope.chainId, + vaultType: requestScope.vaultType, + ...common, + }) + + if (requestId !== activeRequestId || requestContext !== contextKey.value || !isEnabled.value) return + if (mode === 'append' && page.meta.hasMore && page.meta.nextCursor === cursor) { + throw new Error('Activity pagination cursor did not advance') + } + + events.value = mode === 'append' + ? mergeActivityEvents(events.value, page.data) + : mergeActivityEvents([], page.data) + meta.value = page.meta + error.value = undefined + loadMoreError.value = undefined + hasLoaded.value = true + if (mode !== 'append') lastHeadLoadedAt = Date.now() + } + catch (caught) { + if (requestId !== activeRequestId || requestContext !== contextKey.value || !isEnabled.value) return + if (mode === 'append') loadMoreError.value = asError(caught) + else error.value = asError(caught) + hasLoaded.value = true + } + finally { + if (requestId === activeRequestId) { + isLoading.value = false + isRefreshing.value = false + isLoadingMore.value = false + if (pendingInvalidationRefresh && isEnabled.value && hasLoaded.value) { + void fetchPage('refresh') + } + } + } + } + + const refresh = () => fetchPage('refresh') + const loadMore = () => fetchPage('append') + + const unsubscribeFromInvalidations = subscribeToSdkQueryInvalidations((queryNames) => { + const queryName = toValue(scope).kind === 'account' + ? 'queryAccountActivityEvents' + : 'queryVaultActivityEvents' + if (!queryNames.has(queryName)) return + + pendingInvalidationRefresh = true + if (isEnabled.value && hasLoaded.value) void fetchPage('refresh') + }) + + watch([contextKey, isEnabled], ([nextContext, nextEnabled], previous) => { + const previousContext = previous?.[0] + const previousEnabled = previous?.[1] ?? false + const contextChanged = previousContext === undefined || previousContext !== nextContext + + if (contextChanged) { + activeRequestId++ + resetForContext() + } + if (!nextEnabled) { + if (isLoading.value || isRefreshing.value) pendingResumeRefresh = true + activeRequestId++ + isLoading.value = false + isRefreshing.value = false + isLoadingMore.value = false + return + } + if (contextChanged || !hasLoaded.value) { + void fetchPage('initial') + } + else if ( + !previousEnabled + && (pendingInvalidationRefresh || pendingResumeRefresh || isHeadStale() || hasColdError.value) + ) { + void fetchPage('refresh') + } + }, { immediate: true }) + + onScopeDispose(() => { + activeRequestId++ + unsubscribeFromInvalidations() + }) + + return { + coverage, + error, + events, + hasColdError, + hasLoaded, + hasMore, + hasStaleError, + isEmpty, + isLoading, + isLoadingMore, + isPartial, + isRefreshing, + isSyncing, + isUnsupported, + loadMore, + loadMoreError, + meta, + refresh, + } +} diff --git a/server/api/internal/v3/[...path].ts b/server/api/internal/v3/[...path].ts index cf14b8921..fb71ea51a 100644 --- a/server/api/internal/v3/[...path].ts +++ b/server/api/internal/v3/[...path].ts @@ -9,7 +9,7 @@ import { } from 'h3' import { fetchWithTimeout } from '~/server/utils/fetchWithTimeout' import { logger } from '~/server/utils/logger' -import { safePathTemplate, urlHost } from '~/server/utils/observability' +import { safeErrorLogFields, safePathTemplate, urlHost } from '~/server/utils/observability' import { createRateLimiter } from '~/server/utils/rate-limit' import { buildV3ProxyBackoffKey, @@ -87,7 +87,7 @@ export default defineEventHandler(async (event) => { upstreamHost, bodyBytes: body?.length, durationMs: Date.now() - startedAt, - err, + err: safeErrorLogFields(err), }, 'upstream fetch failed', ) diff --git a/server/utils/v3-proxy-backoff.ts b/server/utils/v3-proxy-backoff.ts index b3e8f7ddc..ed741f546 100644 --- a/server/utils/v3-proxy-backoff.ts +++ b/server/utils/v3-proxy-backoff.ts @@ -15,9 +15,46 @@ const normalizeV3ProxyBackoffPath = (pathname: string) => { if (/^\/v3\/earn\/vaults\/[^/]+\/[^/]+$/.test(pathname)) { return '/v3/earn/vaults/:chainId/:vault' } + if (/^\/v3\/activity\/accounts\/[^/]+\/events$/.test(pathname)) { + return '/v3/activity/accounts/:owner/events' + } + const vaultActivity = pathname.match(/^\/v3\/activity\/vaults\/([^/]+)\/[^/]+\/events$/) + if (vaultActivity) { + return `/v3/activity/vaults/${vaultActivity[1]}/:vault/events` + } return pathname } +const ACTIVITY_FILTER_RE = /^(?=.{1,256}$)[a-z][a-z0-9_]*(?:,[a-z][a-z0-9_]*)*$/ +const ACTIVITY_RANGE_RE = /^[0-9]{1,16}$/ +const ACTIVITY_CHAIN_IDS_RE = /^(?=.{1,256}$)[1-9][0-9]{0,15}(?:,[1-9][0-9]{0,15})*$/ + +const buildActivityContextKey = ( + pathname: string, + searchParams?: URLSearchParams, +) => { + if (!/^\/v3\/activity\/(?:accounts\/[^/]+|vaults\/[^/]+\/[^/]+)\/events$/.test(pathname) || !searchParams) { + return pathname + } + + const contextParams = new URLSearchParams() + const safeParams: Array<[string, RegExp]> = [ + ['chainId', ACTIVITY_CHAIN_IDS_RE], + ['vaultType', /^(?:evk|earn|securitize)$/], + ['from', ACTIVITY_RANGE_RE], + ['to', ACTIVITY_RANGE_RE], + ['category', ACTIVITY_FILTER_RE], + ['eventType', ACTIVITY_FILTER_RE], + ] + for (const [name, pattern] of safeParams) { + const value = searchParams.get(name) + if (value && pattern.test(value)) contextParams.set(name, value) + } + + const context = contextParams.toString() + return context ? `${pathname}?${context}` : pathname +} + const buildVaultTotalsRangeKey = ( pathname: string, searchParams?: URLSearchParams, @@ -41,7 +78,10 @@ export const buildV3ProxyBackoffKey = ( pathname: string, searchParams?: URLSearchParams, ) => - `${method.toUpperCase()} ${buildVaultTotalsRangeKey(normalizeV3ProxyBackoffPath(pathname), searchParams)}` + `${method.toUpperCase()} ${buildActivityContextKey( + buildVaultTotalsRangeKey(normalizeV3ProxyBackoffPath(pathname), searchParams), + searchParams, + )}` export const readV3ProxyBackoffMs = ( key: string, diff --git a/server/utils/v3-proxy.ts b/server/utils/v3-proxy.ts index 43274e4d5..f41b07fb7 100644 --- a/server/utils/v3-proxy.ts +++ b/server/utils/v3-proxy.ts @@ -23,6 +23,8 @@ const GET_ONLY_PATHS = new Set([ const GET_ONLY_PATH_PATTERNS = [ /^\/v3\/accounts\/[^/]+\/positions$/, + /^\/v3\/activity\/accounts\/0x[a-fA-F0-9]{40}\/events$/, + /^\/v3\/activity\/vaults\/[1-9][0-9]{0,15}\/0x[a-fA-F0-9]{40}\/events$/, /^\/v3\/earn\/vaults\/[^/]+\/[^/]+$/, /^\/v3\/earn\/vaults\/[^/]+\/[^/]+\/totals$/, /^\/v3\/evk\/vaults\/[^/]+\/[^/]+\/totals$/, @@ -34,8 +36,10 @@ const POST_ONLY_PATHS = new Set([ ]) const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/ -const INTEGER_RE = /^[0-9]+$/ +const INTEGER_RE = /^[0-9]{1,16}$/ +const CHAIN_ID_LIST_RE = /^(?=.{1,256}$)[1-9][0-9]{0,15}(?:,[1-9][0-9]{0,15})*$/ const DECIMAL_RE = /^[0-9]+(?:\.[0-9]+)?$/ +const ACTIVITY_FILTER_RE = /^(?=.{1,256}$)[a-z][a-z0-9_]*(?:,[a-z][a-z0-9_]*)*$/ const SAFE_QUERY_FIELDS = { from: INTEGER_RE, limit: INTEGER_RE, @@ -178,6 +182,43 @@ export function buildV3ProxyLogFields(requestUrl: URL): Record { if (vaultAddress != null) fields.v3VaultAddress = vaultAddress } + if ( + parts.length === 5 + && parts[0] === 'v3' + && parts[1] === 'activity' + && parts[2] === 'accounts' + && ADDRESS_RE.test(parts[3]) + && parts[4] === 'events' + ) { + fields.v3ActivityScope = 'account' + delete fields.v3ChainId + // Account addresses are intentionally omitted. They identify the wallet + // being viewed and are not needed to diagnose upstream activity failures. + const activityChainIds = cleanParam(requestUrl.searchParams.get('chainId'), CHAIN_ID_LIST_RE) + if (activityChainIds != null) fields.v3ChainIds = activityChainIds + } + + if ( + parts.length === 6 + && parts[0] === 'v3' + && parts[1] === 'activity' + && parts[2] === 'vaults' + && INTEGER_RE.test(parts[3]) + && ADDRESS_RE.test(parts[4]) + && parts[5] === 'events' + ) { + fields.v3ActivityScope = 'vault' + fields.v3ChainId = parts[3] + fields.v3VaultAddress = parts[4] + const vaultType = cleanParam(requestUrl.searchParams.get('vaultType'), /^(?:evk|earn|securitize)$/) + if (vaultType != null) fields.v3VaultKind = vaultType + } + + const activityCategories = cleanParam(requestUrl.searchParams.get('category'), ACTIVITY_FILTER_RE) + if (activityCategories != null && fields.v3ActivityScope) fields.v3ActivityCategories = activityCategories + const activityEventTypes = cleanParam(requestUrl.searchParams.get('eventType'), ACTIVITY_FILTER_RE) + if (activityEventTypes != null && fields.v3ActivityScope) fields.v3ActivityEventTypes = activityEventTypes + return { ...fields, ...readSafeQueryFields(requestUrl.searchParams), diff --git a/tests/composables/useActivityAvailability.test.ts b/tests/composables/useActivityAvailability.test.ts new file mode 100644 index 000000000..f24531c9c --- /dev/null +++ b/tests/composables/useActivityAvailability.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref, type EffectScope } from 'vue' + +describe('useActivityAvailability', () => { + let scope: EffectScope | undefined + + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + }) + + afterEach(() => { + scope?.stop() + scope = undefined + }) + + it('does not build the SDK when V3 is disabled for the chain', async () => { + const getEulerSdkForChain = vi.fn() + vi.stubGlobal('useV3ChainGate', () => ({ isV3EnabledForChain: () => false })) + vi.stubGlobal('useEulerSdk', () => ({ getEulerSdkForChain })) + + const { useActivityAvailability } = await import('~/composables/useActivityAvailability') + let availability: ReturnType | undefined + scope = effectScope() + scope.run(() => { + availability = useActivityAvailability({ kind: 'vault', vaultType: 'evk' }, 1) + }) + await nextTick() + + expect(getEulerSdkForChain).not.toHaveBeenCalled() + expect(availability?.isSupported.value).toBe(false) + expect(availability?.reason.value).toBe('v3-disabled') + }) + + it('requires both adapter and scope capability support', async () => { + const getCapabilities = vi.fn(() => ({ + configured: true, + adapter: 'v3', + canQueryAccount: true, + requestableVaultTypes: ['evk', 'earn', 'securitize'] as const, + })) + const getScopeSupport = vi.fn((scope: { kind: string, vaultType?: string }) => + scope.vaultType === 'securitize' ? 'unsupported' as const : 'unknown' as const) + vi.stubGlobal('useV3ChainGate', () => ({ isV3EnabledForChain: () => true })) + vi.stubGlobal('useEulerSdk', () => ({ + getEulerSdkForChain: vi.fn(async () => ({ activityService: { getCapabilities, getScopeSupport } })), + })) + + const { useActivityAvailability } = await import('~/composables/useActivityAvailability') + const availabilityScope = ref<{ + kind: 'vault' + vaultType: 'evk' | 'securitize' + }>({ kind: 'vault', vaultType: 'evk' }) + let availability: ReturnType | undefined + scope = effectScope() + scope.run(() => { + availability = useActivityAvailability(availabilityScope, 1) + }) + + await vi.waitFor(() => expect(availability?.isSupported.value).toBe(true)) + expect(availability?.scopeSupport.value).toBe('unknown') + + availabilityScope.value = { kind: 'vault', vaultType: 'securitize' } + await vi.waitFor(() => expect(availability?.reason.value).toBe('unsupported-scope')) + expect(availability?.isSupported.value).toBe(false) + }) + + it('ignores a capability result from a superseded chain', async () => { + let resolveFirst: ((sdk: unknown) => void) | undefined + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + const getEulerSdkForChain = vi.fn((chainId: number) => chainId === 1 + ? first + : Promise.resolve({ + activityService: { + getCapabilities: () => ({ + configured: true, + adapter: 'v3', + canQueryAccount: true, + requestableVaultTypes: ['evk'], + }), + getScopeSupport: () => 'unknown', + }, + })) + vi.stubGlobal('useV3ChainGate', () => ({ isV3EnabledForChain: () => true })) + vi.stubGlobal('useEulerSdk', () => ({ getEulerSdkForChain })) + + const { useActivityAvailability } = await import('~/composables/useActivityAvailability') + const chainId = ref(1) + let availability: ReturnType | undefined + scope = effectScope() + scope.run(() => { + availability = useActivityAvailability({ kind: 'vault', vaultType: 'evk' }, chainId) + }) + + await vi.waitFor(() => expect(getEulerSdkForChain).toHaveBeenCalledWith(1)) + chainId.value = 8453 + await vi.waitFor(() => expect(availability?.isSupported.value).toBe(true)) + + resolveFirst?.({ + activityService: { + getCapabilities: () => ({ + configured: false, + adapter: null, + canQueryAccount: false, + requestableVaultTypes: [], + reason: 'source-not-configured', + }), + getScopeSupport: () => 'unsupported', + }, + }) + await nextTick() + + expect(availability?.isSupported.value).toBe(true) + expect(availability?.reason.value).toBeUndefined() + }) + + it('keeps capability-check failures renderable and retryable', async () => { + const getEulerSdkForChain = vi.fn() + .mockRejectedValueOnce(new Error('SDK build failed')) + .mockResolvedValueOnce({ + activityService: { + getCapabilities: () => ({ + configured: true, + adapter: 'v3', + canQueryAccount: true, + requestableVaultTypes: ['evk'], + }), + getScopeSupport: () => 'unknown', + }, + }) + vi.stubGlobal('useV3ChainGate', () => ({ isV3EnabledForChain: () => true })) + vi.stubGlobal('useEulerSdk', () => ({ getEulerSdkForChain })) + + const { useActivityAvailability } = await import('~/composables/useActivityAvailability') + let availability: ReturnType | undefined + scope = effectScope() + scope.run(() => { + availability = useActivityAvailability({ kind: 'vault', vaultType: 'evk' }, 1) + }) + + await vi.waitFor(() => expect(availability?.reason.value).toBe('capability-check-failed')) + expect(availability?.isSupported.value).toBe(false) + expect(availability?.shouldRender.value).toBe(true) + + const retry = availability?.refreshAvailability() + expect(availability?.shouldRender.value).toBe(true) + expect(availability?.isChecking.value).toBe(true) + await retry + + expect(availability?.isSupported.value).toBe(true) + expect(availability?.shouldRender.value).toBe(true) + expect(availability?.reason.value).toBeUndefined() + }) +}) diff --git a/tests/composables/useActivityFeed.test.ts b/tests/composables/useActivityFeed.test.ts new file mode 100644 index 000000000..c49c17523 --- /dev/null +++ b/tests/composables/useActivityFeed.test.ts @@ -0,0 +1,434 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref, type EffectScope } from 'vue' +import { buildActivityFeedContextKey } from '~/composables/useActivityFeed' + +const VAULT = '0x0000000000000000000000000000000000000001' as const +const OTHER_VAULT = '0x0000000000000000000000000000000000000002' as const +const TX_HASH = `0x${'1'.repeat(64)}` as const + +const event = (id: string, vault: typeof VAULT | typeof OTHER_VAULT = VAULT) => ({ + id, + chainId: 1, + type: 'deposit', + rawType: 'deposit', + category: 'lending' as const, + timestamp: '2026-07-13T10:30:00.000Z', + blockNumber: '123', + logIndex: 0, + txHash: TX_HASH, + source: 'v3-ponder', + payload: {}, + vault, +}) + +const page = ( + data: ReturnType[], + { nextCursor = null, status = 'complete' }: { + nextCursor?: string | null + status?: 'complete' | 'partial' | 'unsupported' | 'syncing' + } = {}, +) => ({ + data, + meta: { + hasMore: nextCursor !== null, + nextCursor, + source: 'v3-ponder', + timestamp: '2026-07-13T10:30:00.000Z', + coverage: { + status, + chains: [{ chainId: 1, status, missingCategories: [] }], + missingCategories: [], + }, + }, +}) + +describe('useActivityFeed', () => { + let effect: EffectScope | undefined + + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + }) + + afterEach(() => { + effect?.stop() + effect = undefined + vi.restoreAllMocks() + }) + + const setup = async (fetchVaultActivityEvents: ReturnType) => { + vi.stubGlobal('useEulerSdk', () => ({ + getEulerSdkForChain: vi.fn(async () => ({ + activityService: { fetchVaultActivityEvents }, + })), + })) + return import('~/composables/useActivityFeed') + } + + it('changes the key used to remount the activity accordion when vault scope changes', () => { + expect(buildActivityFeedContextKey( + { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + [], + )).not.toBe(buildActivityFeedContextKey( + { kind: 'vault', vault: OTHER_VAULT, chainId: 1, vaultType: 'evk' }, + [], + )) + }) + + it('does not request activity until enabled and forwards server-side categories', async () => { + const fetchVaultActivityEvents = vi.fn(async () => page([event('one')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const enabled = ref(false) + const categories = ref(['lending', 'governance'] as const) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled, + categories, + }) + }) + await nextTick() + expect(fetchVaultActivityEvents).not.toHaveBeenCalled() + + enabled.value = true + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['one'])) + expect(fetchVaultActivityEvents).toHaveBeenCalledWith({ + vault: VAULT, + chainId: 1, + vaultType: 'evk', + categories: ['governance', 'lending'], + limit: 25, + }) + }) + + it('retains loaded rows without refetching when collapsed and reopened', async () => { + const fetchVaultActivityEvents = vi.fn(async () => page([event('one')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const enabled = ref(true) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled, + categories: ['lending'], + }) + }) + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['one'])) + enabled.value = false + await nextTick() + enabled.value = true + await nextTick() + + expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(1) + expect(feed?.events.value.map(item => item.id)).toEqual(['one']) + }) + + it('refreshes stale rows when reopened while retaining the last-good page', async () => { + let now = 1_000 + vi.spyOn(Date, 'now').mockImplementation(() => now) + let resolveRefresh: ((value: ReturnType) => void) | undefined + const refresh = new Promise>((resolve) => { + resolveRefresh = resolve + }) + const fetchVaultActivityEvents = vi.fn() + .mockResolvedValueOnce(page([event('one')])) + .mockReturnValueOnce(refresh) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const enabled = ref(true) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled, + categories: ['lending'], + }) + }) + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['one'])) + enabled.value = false + await nextTick() + now += 60_001 + enabled.value = true + + await vi.waitFor(() => expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(2)) + expect(feed?.events.value.map(item => item.id)).toEqual(['one']) + expect(feed?.isRefreshing.value).toBe(true) + + resolveRefresh?.(page([event('new')])) + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['new'])) + }) + + it('retries a cold failure when the collapsed feed is opened', async () => { + const fetchVaultActivityEvents = vi.fn() + .mockRejectedValueOnce(new Error('backend unavailable')) + .mockResolvedValueOnce(page([event('recovered')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const enabled = ref(true) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled, + categories: ['lending'], + }) + }) + + await vi.waitFor(() => expect(feed?.hasColdError.value).toBe(true)) + enabled.value = false + await nextTick() + enabled.value = true + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['recovered'])) + expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(2) + }) + + it('retries a cold refresh that was interrupted by collapsing the feed', async () => { + let resolveInterrupted: ((value: ReturnType) => void) | undefined + const interrupted = new Promise>((resolve) => { + resolveInterrupted = resolve + }) + const fetchVaultActivityEvents = vi.fn() + .mockRejectedValueOnce(new Error('backend unavailable')) + .mockReturnValueOnce(interrupted) + .mockResolvedValueOnce(page([event('recovered')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const enabled = ref(true) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled, + categories: ['lending'], + }) + }) + + await vi.waitFor(() => expect(feed?.hasColdError.value).toBe(true)) + enabled.value = false + await nextTick() + enabled.value = true + await vi.waitFor(() => expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(2)) + + enabled.value = false + await nextTick() + enabled.value = true + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['recovered'])) + expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(3) + + resolveInterrupted?.(page([event('interrupted')])) + await nextTick() + expect(feed?.events.value.map(item => item.id)).toEqual(['recovered']) + }) + + it('refreshes an invalidated activity query when the feed is reopened', async () => { + const fetchVaultActivityEvents = vi.fn() + .mockResolvedValueOnce(page([event('one')])) + .mockResolvedValueOnce(page([event('new')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const enabled = ref(true) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled, + categories: ['lending'], + }) + }) + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['one'])) + enabled.value = false + await nextTick() + + const { invalidateSdkQueries } = await import('~/utils/sdk-query-cache') + await invalidateSdkQueries(['queryVaultActivityEvents' as never]) + expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(1) + + enabled.value = true + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['new'])) + expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(2) + }) + + it('refreshes an invalidated activity query while the feed is open', async () => { + const fetchVaultActivityEvents = vi.fn() + .mockResolvedValueOnce(page([event('one')])) + .mockResolvedValueOnce(page([event('new')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled: true, + categories: ['lending'], + }) + }) + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['one'])) + const { invalidateSdkQueries } = await import('~/utils/sdk-query-cache') + await invalidateSdkQueries(['queryVaultActivityEvents' as never]) + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['new'])) + expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(2) + }) + + it('retries an invalidation refresh that was interrupted by collapsing the feed', async () => { + let resolveInterrupted: ((value: ReturnType) => void) | undefined + const interrupted = new Promise>((resolve) => { + resolveInterrupted = resolve + }) + const fetchVaultActivityEvents = vi.fn() + .mockResolvedValueOnce(page([event('one')])) + .mockReturnValueOnce(interrupted) + .mockResolvedValueOnce(page([event('new')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const enabled = ref(true) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled, + categories: ['lending'], + }) + }) + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['one'])) + const { invalidateSdkQueries } = await import('~/utils/sdk-query-cache') + await invalidateSdkQueries(['queryVaultActivityEvents' as never]) + await vi.waitFor(() => expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(2)) + + enabled.value = false + await nextTick() + enabled.value = true + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['new'])) + expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(3) + + resolveInterrupted?.(page([event('interrupted')])) + await nextTick() + expect(feed?.events.value.map(item => item.id)).toEqual(['new']) + }) + + it('ignores superseded context responses', async () => { + let resolveFirst: ((value: ReturnType) => void) | undefined + let resolveSecond: ((value: ReturnType) => void) | undefined + const first = new Promise>((resolve) => { + resolveFirst = resolve + }) + const second = new Promise>((resolve) => { + resolveSecond = resolve + }) + const fetchVaultActivityEvents = vi.fn(({ vault }: { vault: string }) => vault === VAULT ? first : second) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + const feedScope = ref<{ + kind: 'vault' + vault: typeof VAULT | typeof OTHER_VAULT + chainId: 1 + vaultType: 'evk' + }>({ kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ scope: feedScope, enabled: true, categories: [] }) + }) + + await vi.waitFor(() => expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(1)) + feedScope.value = { kind: 'vault', vault: OTHER_VAULT, chainId: 1, vaultType: 'evk' } + await vi.waitFor(() => expect(fetchVaultActivityEvents).toHaveBeenCalledTimes(2)) + + resolveSecond?.(page([event('new', OTHER_VAULT)])) + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual(['new'])) + resolveFirst?.(page([event('old')])) + await nextTick() + + expect(feed?.events.value.map(item => item.id)).toEqual(['new']) + }) + + it('retains last-good rows when a same-context refresh fails', async () => { + const fetchVaultActivityEvents = vi.fn() + .mockResolvedValueOnce(page([event('one')])) + .mockRejectedValueOnce(new Error('backend unavailable')) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled: true, + categories: [], + }) + }) + + await vi.waitFor(() => expect(feed?.events.value).toHaveLength(1)) + await feed?.refresh() + + expect(feed?.events.value.map(item => item.id)).toEqual(['one']) + expect(feed?.hasStaleError.value).toBe(true) + expect(feed?.hasColdError.value).toBe(false) + expect(feed?.isEmpty.value).toBe(false) + }) + + it('keeps load-more failures separate and deduplicates a retried page', async () => { + const fetchVaultActivityEvents = vi.fn() + .mockResolvedValueOnce(page([event('one')], { nextCursor: 'next' })) + .mockRejectedValueOnce(new Error('older page unavailable')) + .mockResolvedValueOnce(page([event('one'), event('two')])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled: true, + categories: [], + }) + }) + + await vi.waitFor(() => expect(feed?.hasMore.value).toBe(true)) + await feed?.loadMore() + expect(feed?.events.value.map(item => item.id)).toEqual(['one']) + expect(feed?.loadMoreError.value?.message).toBe('older page unavailable') + expect(feed?.error.value).toBeUndefined() + + await feed?.loadMore() + expect(fetchVaultActivityEvents).toHaveBeenLastCalledWith(expect.objectContaining({ cursor: 'next' })) + expect(feed?.events.value.map(item => item.id)).toEqual(['one', 'two']) + expect(feed?.loadMoreError.value).toBeUndefined() + }) + + it('distinguishes empty, partial, syncing and unsupported coverage', async () => { + const fetchVaultActivityEvents = vi.fn() + .mockResolvedValueOnce(page([], { status: 'complete' })) + .mockResolvedValueOnce(page([event('partial')], { status: 'partial' })) + .mockResolvedValueOnce(page([], { status: 'syncing' })) + .mockResolvedValueOnce(page([], { status: 'unsupported' })) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled: true, + categories: [], + }) + }) + + await vi.waitFor(() => expect(feed?.isEmpty.value).toBe(true)) + await feed?.refresh() + expect(feed?.isPartial.value).toBe(true) + await feed?.refresh() + expect(feed?.isSyncing.value).toBe(true) + expect(feed?.isEmpty.value).toBe(false) + await feed?.refresh() + expect(feed?.isUnsupported.value).toBe(true) + expect(feed?.isEmpty.value).toBe(false) + }) +}) diff --git a/tests/server/v3-proxy-backoff.test.ts b/tests/server/v3-proxy-backoff.test.ts new file mode 100644 index 000000000..70806adeb --- /dev/null +++ b/tests/server/v3-proxy-backoff.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { buildV3ProxyBackoffKey } from '~/server/utils/v3-proxy-backoff' + +const ACCOUNT = '0x0000000000000000000000000000000000000001' +const OTHER_ACCOUNT = '0x0000000000000000000000000000000000000002' +const VAULT = '0x0000000000000000000000000000000000000003' +const OTHER_VAULT = '0x0000000000000000000000000000000000000004' + +describe('activity V3 proxy backoff keys', () => { + it('does not key account cooldowns by wallet address', () => { + const params = new URLSearchParams({ + chainId: '1', + from: '1782380000', + to: '1782984800', + category: 'lending,borrowing', + cursor: 'private-opaque-cursor', + limit: '25', + }) + + const first = buildV3ProxyBackoffKey('GET', `/v3/activity/accounts/${ACCOUNT}/events`, params) + const second = buildV3ProxyBackoffKey('GET', `/v3/activity/accounts/${OTHER_ACCOUNT}/events`, params) + + expect(first).toBe(second) + expect(first).toBe('GET /v3/activity/accounts/:owner/events?chainId=1&from=1782380000&to=1782984800&category=lending%2Cborrowing') + expect(first).not.toContain(ACCOUNT) + expect(first).not.toContain('private-opaque-cursor') + }) + + it('keeps account cooldowns scoped to chain and time window', () => { + const first = buildV3ProxyBackoffKey( + 'GET', + `/v3/activity/accounts/${ACCOUNT}/events`, + new URLSearchParams({ chainId: '1', from: '100', to: '200' }), + ) + const otherWindow = buildV3ProxyBackoffKey( + 'GET', + `/v3/activity/accounts/${ACCOUNT}/events`, + new URLSearchParams({ chainId: '1', from: '200', to: '300' }), + ) + const otherChain = buildV3ProxyBackoffKey( + 'GET', + `/v3/activity/accounts/${ACCOUNT}/events`, + new URLSearchParams({ chainId: '8453', from: '100', to: '200' }), + ) + + expect(first).not.toBe(otherWindow) + expect(first).not.toBe(otherChain) + }) + + it('omits unbounded activity context values', () => { + const key = buildV3ProxyBackoffKey( + 'GET', + `/v3/activity/accounts/${ACCOUNT}/events`, + new URLSearchParams({ + chainId: '1'.repeat(300), + from: '1'.repeat(17), + category: 'a'.repeat(300), + }), + ) + + expect(key).toBe('GET /v3/activity/accounts/:owner/events') + }) + + it('normalizes public vault addresses while preserving kind and chain context', () => { + const params = new URLSearchParams({ vaultType: 'earn', category: 'governance' }) + const first = buildV3ProxyBackoffKey('GET', `/v3/activity/vaults/1/${VAULT}/events`, params) + const second = buildV3ProxyBackoffKey('GET', `/v3/activity/vaults/1/${OTHER_VAULT}/events`, params) + const otherChain = buildV3ProxyBackoffKey('GET', `/v3/activity/vaults/8453/${VAULT}/events`, params) + + expect(first).toBe(second) + expect(first).toBe('GET /v3/activity/vaults/1/:vault/events?vaultType=earn&category=governance') + expect(first).not.toBe(otherChain) + expect(first).not.toContain(VAULT) + }) +}) diff --git a/tests/server/v3-proxy-route.test.ts b/tests/server/v3-proxy-route.test.ts index 1fd4832ec..c1cadf1b2 100644 --- a/tests/server/v3-proxy-route.test.ts +++ b/tests/server/v3-proxy-route.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ consume: vi.fn(), fetchWithTimeout: vi.fn(), rateLimiterConfigs: [] as Array<{ max: number, windowMs: number, label: string }>, + warn: vi.fn(), })) vi.mock('h3', () => ({ @@ -41,6 +42,10 @@ vi.mock('~/server/utils/rate-limit', () => ({ }, })) +vi.mock('~/server/utils/logger', () => ({ + logger: { warn: mocks.warn }, +})) + type TestEvent = H3Event & { method: string url: string @@ -295,6 +300,36 @@ describe('/api/internal/v3 proxy route', () => { expect(mocks.fetchWithTimeout).toHaveBeenCalledTimes(1) }) + it('sanitizes owner-scoped activity transport errors before logging', async () => { + const cursor = 'private-opaque-cursor' + const requestUrl = `https://app.example/api/internal/v3/activity/accounts/${ACCOUNT}/events?chainId=1&cursor=${cursor}` + const cause = new TypeError(`Failed to fetch ${requestUrl}`) + mocks.fetchWithTimeout.mockRejectedValueOnce(Object.assign( + new Error(`Activity request failed for ${requestUrl}`), + { code: 'UND_ERR_CONNECT_TIMEOUT', cause }, + )) + + await expect(handler(makeEvent('GET', requestUrl))).rejects.toMatchObject({ + statusCode: 503, + statusMessage: 'V3 upstream unavailable', + }) + + const logRecord = mocks.warn.mock.calls.find(([, message]) => message === 'upstream fetch failed')?.[0] + expect(logRecord).toMatchObject({ + ctx: 'v3-proxy', + pathTemplate: '/v3/activity/accounts/:address/events', + v3ActivityScope: 'account', + v3ChainIds: '1', + err: { + name: 'Error', + code: 'UND_ERR_CONNECT_TIMEOUT', + causeName: 'TypeError', + }, + }) + expect(JSON.stringify(logRecord)).not.toContain(ACCOUNT) + expect(JSON.stringify(logRecord)).not.toContain(cursor) + }) + it('shares cooldown across dynamic account position paths', async () => { mocks.fetchWithTimeout.mockRejectedValueOnce(new Error('timeout')) const first = makeEvent('GET', `https://app.example/api/internal/v3/accounts/${ACCOUNT}/positions?chainId=1`) diff --git a/tests/server/v3-proxy.test.ts b/tests/server/v3-proxy.test.ts index ce98bf904..b9202c08e 100644 --- a/tests/server/v3-proxy.test.ts +++ b/tests/server/v3-proxy.test.ts @@ -32,6 +32,8 @@ describe('v3 proxy utilities', () => { it('allows only SDK-owned V3 path shapes', () => { expect(isV3ProxyPathAllowed(`/v3/accounts/${ACCOUNT}/positions`)).toBe(true) + expect(isV3ProxyPathAllowed(`/v3/activity/accounts/${ACCOUNT}/events`)).toBe(true) + expect(isV3ProxyPathAllowed(`/v3/activity/vaults/1/${VAULT}/events`)).toBe(true) expect(isV3ProxyPathAllowed('/v3/apys/intrinsic')).toBe(true) expect(isV3ProxyPathAllowed('/v3/apys/rewards')).toBe(true) expect(isV3ProxyPathAllowed(`/v3/earn/vaults/1/${VAULT}`)).toBe(true) @@ -54,6 +56,11 @@ describe('v3 proxy utilities', () => { expect(isV3ProxyPathAllowed('/v3/evk/vaults-admin')).toBe(false) expect(isV3ProxyPathAllowed('/v3/apys/unknown')).toBe(false) expect(isV3ProxyPathAllowed('/v3/resolve')).toBe(false) + expect(isV3ProxyPathAllowed('/v3/activity/accounts/not-an-address/events')).toBe(false) + expect(isV3ProxyPathAllowed(`/v3/activity/accounts/${ACCOUNT}/events/admin`)).toBe(false) + expect(isV3ProxyPathAllowed(`/v3/activity/vaults/0/${VAULT}/events`)).toBe(false) + expect(isV3ProxyPathAllowed(`/v3/activity/vaults/${'1'.repeat(17)}/${VAULT}/events`)).toBe(false) + expect(isV3ProxyPathAllowed(`/v3/activity/vaults/1/not-an-address/events`)).toBe(false) }) it('allows query strings on SDK-owned endpoints for V3 to validate', () => { @@ -85,6 +92,14 @@ describe('v3 proxy utilities', () => { 'GET', new URL('https://app.example/api/internal/v3/tokens?chainId=1&limit=500&type=base'), )).toEqual({ ok: true }) + expect(validateV3ProxyUrl( + 'GET', + new URL(`https://app.example/api/internal/v3/activity/accounts/${ACCOUNT}/events?chainId=1,8453&category=lending,borrowing&cursor=opaque`), + )).toEqual({ ok: true }) + expect(validateV3ProxyUrl( + 'GET', + new URL(`https://app.example/api/internal/v3/activity/vaults/1/${VAULT}/events?vaultType=evk&category=governance`), + )).toEqual({ ok: true }) expect(validateV3ProxyUrl( 'GET', new URL(`https://app.example/api/internal/v3/prices?chainId=1&assets=${ACCOUNT}&limit=100&debug=true`), @@ -144,6 +159,40 @@ describe('v3 proxy utilities', () => { }) }) + it('logs bounded activity context without account addresses', () => { + expect(buildV3ProxyLogFields( + new URL(`https://app.example/api/internal/v3/activity/accounts/${ACCOUNT}/events?chainId=1,8453&from=1782380000&to=1782984800&category=lending,borrowing&eventType=deposit,borrow&offset=0&limit=25`), + )).toEqual({ + v3ActivityCategories: 'lending,borrowing', + v3ActivityEventTypes: 'deposit,borrow', + v3ActivityScope: 'account', + v3ChainIds: '1,8453', + v3From: '1782380000', + v3Limit: '25', + v3Offset: '0', + v3To: '1782984800', + }) + + expect(buildV3ProxyLogFields( + new URL(`https://app.example/api/internal/v3/activity/accounts/${ACCOUNT}/events?chainId=${'1'.repeat(300)}&from=${'1'.repeat(17)}&category=${'a'.repeat(300)}`), + )).toEqual({ + v3ActivityScope: 'account', + }) + }) + + it('logs public vault activity context', () => { + expect(buildV3ProxyLogFields( + new URL(`https://app.example/api/internal/v3/activity/vaults/1/${VAULT}/events?vaultType=securitize&category=lending,governance&limit=25`), + )).toEqual({ + v3ActivityCategories: 'lending,governance', + v3ActivityScope: 'vault', + v3ChainId: '1', + v3Limit: '25', + v3VaultAddress: VAULT, + v3VaultKind: 'securitize', + }) + }) + it('injects only fixed SDK headers and the server-side API key', () => { const headers = buildV3ProxyRequestHeaders('POST', { EULER_SDK_V3_API_KEY: 'server-key', diff --git a/tests/utils/activity-display.test.ts b/tests/utils/activity-display.test.ts new file mode 100644 index 000000000..9a9244221 --- /dev/null +++ b/tests/utils/activity-display.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { + enrichActivityAssetForDisplay, + formatActivityAssetAmount, + formatActivityAssetUsd, + formatActivityEventLabel, + formatActivityTimestamp, + formatActivityValuation, + getActivityAssetAddressLabel, + getActivityAssetLabel, + getActivityChangeEntries, + getActivityParticipants, + getVaultActivityFilterOptions, + isActivityScopeUnsupported, + resolveActivityFilterCategories, +} from '~/utils/activity-display' + +const ASSET = '0x0000000000000000000000000000000000000001' as const +const VAULT = '0x0000000000000000000000000000000000000002' as const +const SHARES = '0x0000000000000000000000000000000000000003' as const + +describe('activity display helpers', () => { + it('returns vault-specific category filters', () => { + expect(getVaultActivityFilterOptions('evk')).toEqual([ + { value: 'user-operations', label: 'User operations', categories: ['lending', 'borrowing'] }, + { value: 'governance', label: 'Governance', categories: ['governance'] }, + { value: 'liquidations', label: 'Liquidations', categories: ['liquidations'] }, + ]) + expect(getVaultActivityFilterOptions('evk', { borrowable: false })).toEqual([ + { value: 'user-operations', label: 'User operations', categories: ['lending'] }, + { value: 'governance', label: 'Governance', categories: ['governance'] }, + ]) + expect(getVaultActivityFilterOptions('earn')).toEqual([ + { value: 'user-operations', label: 'User operations', categories: ['lending'] }, + { value: 'governance', label: 'Governance', categories: ['governance'] }, + ]) + expect(getVaultActivityFilterOptions('securitize')).toEqual([ + { value: 'user-operations', label: 'User operations', categories: ['lending'] }, + { value: 'governance', label: 'Governance', categories: ['governance'] }, + ]) + + const options = getVaultActivityFilterOptions('evk', { borrowable: false }) + expect(resolveActivityFilterCategories(options, [])).toEqual(['governance', 'lending']) + expect(resolveActivityFilterCategories(options, ['user-operations'])).toEqual(['lending']) + }) + + it('treats only explicit-All unsupported coverage as scope-wide', () => { + expect(isActivityScopeUnsupported('unsupported', [])).toBe(true) + expect(isActivityScopeUnsupported('unsupported', ['governance'])).toBe(false) + expect(isActivityScopeUnsupported('partial', [])).toBe(false) + }) + + it('uses normalized labels and titleizes fallback event types', () => { + expect(formatActivityEventLabel({ label: 'Borrowed USDC', type: 'borrow' })).toBe('Borrowed USDC') + expect(formatActivityEventLabel({ type: 'set_supply_cap' })).toBe('Set supply cap') + }) + + it('formats normalized and raw asset amounts without inventing USD values', () => { + expect(formatActivityAssetAmount({ kind: 'assets', address: ASSET, amountRaw: '1234500000', amount: '1234.5', symbol: 'USDC' })).toBe('1,234.50 USDC') + expect(formatActivityAssetAmount({ kind: 'assets', address: ASSET, amountRaw: '1500000', decimals: 6, symbol: 'USDC' })).toBe('1.5 USDC') + expect(formatActivityAssetAmount({ kind: 'assets', amountRaw: '1500000' })).toBe('Raw: 1,500,000') + expect(formatActivityAssetUsd({ kind: 'assets', amountRaw: '1', amountUsd: '1234.5' })).toBe('$1.23K') + expect(formatActivityAssetUsd({ kind: 'assets', amountRaw: '1' })).toBeNull() + expect(formatActivityValuation({ status: 'unavailable', reason: 'No historical price' })).toBe('USD value unavailable') + expect(formatActivityValuation({ status: 'partial', amountUsd: '1234.5' })).toBe('$1.23K (partial)') + }) + + it('enriches raw amounts from registry metadata without overriding source fields', () => { + const getVaultMetadata = (address: string) => address.toLowerCase() === VAULT.toLowerCase() + ? { + asset: { address: ASSET, symbol: 'USDC', decimals: 6 }, + shares: { address: SHARES, symbol: 'eUSDC', decimals: 18 }, + } + : undefined + + expect(enrichActivityAssetForDisplay( + { kind: 'assets', amountRaw: '1500000' }, + { category: 'lending', vault: VAULT }, + getVaultMetadata, + )).toEqual({ + kind: 'assets', + amountRaw: '1500000', + address: ASSET, + symbol: 'USDC', + decimals: 6, + }) + + expect(enrichActivityAssetForDisplay( + { kind: 'shares', amountRaw: '1', address: VAULT, symbol: 'Source shares' }, + { category: 'lending', vault: VAULT }, + getVaultMetadata, + )).toEqual({ + kind: 'shares', + amountRaw: '1', + address: VAULT, + symbol: 'Source shares', + decimals: 18, + }) + + const unpriced = enrichActivityAssetForDisplay( + { kind: 'assets', amountRaw: '1500000' }, + { category: 'lending', vault: VAULT }, + getVaultMetadata, + ) + expect(unpriced.amountUsd).toBeUndefined() + }) + + it('formats all governance changes and category-specific liquidation details', () => { + expect(getActivityChangeEntries({ + fields: { + supply_cap: '2000', + is_allocator: true, + queue: ['one', 'two'], + }, + })).toEqual([ + { field: 'supply_cap', label: 'Supply cap', value: '2000' }, + { field: 'is_allocator', label: 'Is allocator', value: 'Enabled' }, + { field: 'queue', label: 'Queue', value: 'one, two' }, + ]) + expect(getActivityAssetLabel('assets', 'liquidations')).toBe('Debt repaid') + expect(getActivityAssetLabel('collateral', 'liquidations')).toBe('Collateral seized') + expect(getActivityAssetAddressLabel('assets', 'liquidations')).toBe('Debt vault') + expect(getActivityAssetAddressLabel('collateral', 'liquidations')).toBe('Collateral vault') + + expect(getActivityParticipants({ + category: 'liquidations', + actor: ASSET, + counterparty: '0x0000000000000000000000000000000000000002', + })).toEqual([ + { label: 'Liquidator', address: ASSET }, + { label: 'Violator', address: '0x0000000000000000000000000000000000000002' }, + ]) + }) + + it('formats timestamps', () => { + expect(formatActivityTimestamp('not-a-date')).toBe('-') + expect(formatActivityTimestamp('2026-07-13T10:30:00.000Z')).toContain('13 Jul 2026') + }) +}) diff --git a/utils/activity-display.ts b/utils/activity-display.ts new file mode 100644 index 000000000..30934b1e8 --- /dev/null +++ b/utils/activity-display.ts @@ -0,0 +1,292 @@ +import type { + ActivityAssetAmount, + ActivityAssetKind, + ActivityCategory, + ActivityChange, + ActivityChangeValue, + ActivityCoverageStatus, + ActivityValuation, + ActivityVaultType, +} from '@eulerxyz/euler-v2-sdk' +import { formatUnits, type Address } from 'viem' +import { formatCompactUsdValue, formatSmartAmount } from '~/utils/string-utils' + +interface ActivityTokenMetadata { + address: Address + symbol: string + decimals: number +} + +interface ActivityVaultMetadata { + asset?: ActivityTokenMetadata + shares?: ActivityTokenMetadata +} + +interface ActivityAssetContext { + category: ActivityCategory + vault?: Address +} + +type ActivityVaultMetadataLookup = (address: Address) => ActivityVaultMetadata | undefined + +const CATEGORY_LABELS: Record = { + lending: 'Lending', + borrowing: 'Borrowing', + swaps: 'Swaps', + liquidations: 'Liquidations', + account: 'Account', + rewards: 'Rewards', + governance: 'Governance', +} + +export interface ActivityFilterOption { + value: string + label: string + categories: readonly ActivityCategory[] +} + +export const resolveActivityFilterCategories = ( + options: readonly ActivityFilterOption[], + selectedFilters: readonly string[], +): ActivityCategory[] => { + const selected = new Set(selectedFilters) + const activeOptions = selected.size === 0 + ? options + : options.filter(option => selected.has(option.value)) + return [...new Set(activeOptions.flatMap(option => option.categories))].sort() +} + +export const isActivityScopeUnsupported = ( + coverageStatus: ActivityCoverageStatus | undefined, + selectedFilters: readonly string[], +): boolean => coverageStatus === 'unsupported' && selectedFilters.length === 0 + +const CATEGORY_ICONS: Record = { + lending: 'lend-outline', + borrowing: 'borrow-outline', + swaps: 'swap-horizontal', + liquidations: 'warning', + account: 'wallet', + rewards: 'sparks', + governance: 'governed', +} + +export const getActivityCategoryLabel = (category: ActivityCategory): string => + CATEGORY_LABELS[category] + +export const getActivityCategoryIcon = (category: ActivityCategory): string => + CATEGORY_ICONS[category] + +export const getVaultActivityFilterOptions = ( + vaultType: ActivityVaultType, + { borrowable = true }: { borrowable?: boolean } = {}, +): ActivityFilterOption[] => { + const userCategories: ActivityCategory[] = vaultType === 'evk' && borrowable + ? ['lending', 'borrowing'] + : ['lending'] + const options: ActivityFilterOption[] = [ + { value: 'user-operations', label: 'User operations', categories: userCategories }, + { value: 'governance', label: 'Governance', categories: ['governance'] }, + ] + if (vaultType === 'evk' && borrowable) { + options.push({ value: 'liquidations', label: 'Liquidations', categories: ['liquidations'] }) + } + return options +} + +export const titleizeActivityType = (type: string): string => { + const words = type + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .trim() + return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Activity' +} + +export const formatActivityEventLabel = ( + event: { label?: string, type: string }, +): string => event.label?.trim() || titleizeActivityType(event.type) + +export const formatActivityTimestamp = (timestamp: string): string => { + const date = new Date(timestamp) + if (!Number.isFinite(date.getTime())) return '-' + return new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +const resolveAssetAmount = (asset: ActivityAssetAmount): string | undefined => { + if (asset.amount !== undefined && asset.amount.trim()) return asset.amount + if (asset.amountRaw === undefined || asset.decimals === undefined) return undefined + try { + return formatUnits(BigInt(asset.amountRaw), asset.decimals) + } + catch { + return undefined + } +} + +export const formatActivityAssetAmount = (asset: ActivityAssetAmount): string => { + const amount = resolveAssetAmount(asset) + if (amount === undefined) { + try { + return `Raw: ${BigInt(asset.amountRaw).toLocaleString('en-US')}` + } + catch { + return 'Amount unavailable' + } + } + const formatted = formatSmartAmount(amount) + return asset.symbol ? `${formatted} ${asset.symbol}` : formatted +} + +/** + * Fills display-only token metadata from Lite's existing vault registry. + * Fields supplied by the activity source always win, and historical USD value + * remains source-owned. + */ +export const enrichActivityAssetForDisplay = ( + asset: ActivityAssetAmount, + event: ActivityAssetContext, + getVaultMetadata: ActivityVaultMetadataLookup, +): ActivityAssetAmount => { + let token: ActivityTokenMetadata | undefined + + if (asset.kind === 'assets' && event.vault) { + token = getVaultMetadata(event.vault)?.asset + } + else if (asset.kind === 'shares') { + const shareVault = asset.address ?? event.vault + if (shareVault) token = getVaultMetadata(shareVault)?.shares + } + else if ( + event.category === 'liquidations' + && (asset.kind === 'collateral' || asset.kind === 'yield') + && asset.address + ) { + token = getVaultMetadata(asset.address)?.shares + } + + if (!token) return asset + return { + ...asset, + address: asset.address ?? token.address, + symbol: asset.symbol ?? token.symbol, + decimals: asset.decimals ?? token.decimals, + } +} + +export const formatActivityAssetUsd = (asset: ActivityAssetAmount): string | null => + asset.amountUsd === undefined + ? null + : formatCompactUsdValue(asset.amountUsd) + +const ASSET_KIND_LABELS: Record = { + assets: 'Assets', + shares: 'Shares', + value: 'Value', + collateral: 'Collateral', + yield: 'Yield', +} + +export const getActivityAssetLabel = ( + kind: ActivityAssetKind, + category: ActivityCategory, +): string => { + if (category === 'liquidations' && kind === 'assets') return 'Debt repaid' + if (category === 'liquidations' && (kind === 'collateral' || kind === 'yield')) { + return 'Collateral seized' + } + return ASSET_KIND_LABELS[kind] +} + +export const getActivityAssetAddressLabel = ( + kind: ActivityAssetKind, + category: ActivityCategory, +): string => { + if (category === 'liquidations' && kind === 'assets') return 'Debt vault' + if (category === 'liquidations' && (kind === 'collateral' || kind === 'yield')) { + return 'Collateral vault' + } + return 'Asset' +} + +export const formatActivityChangeValue = (value: ActivityChangeValue): string => { + if (value === null) return 'None' + if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled' + if (Array.isArray(value)) return value.length ? value.join(', ') : 'None' + return String(value) +} + +export interface ActivityChangeEntry { + field: string + label: string + value: string +} + +export const getActivityChangeEntries = ( + change: ActivityChange | undefined, +): ActivityChangeEntry[] => Object.entries(change?.fields ?? {}).map(([field, value]) => ({ + field, + label: titleizeActivityType(field), + value: formatActivityChangeValue(value), +})) + +export interface ActivityParticipant { + address: Address + label: string +} + +interface ActivityParticipantSource { + account?: Address + actor?: Address + category: ActivityCategory + counterparty?: Address + owner?: Address +} + +export const getActivityParticipants = ( + event: ActivityParticipantSource, +): ActivityParticipant[] => { + const participants: ActivityParticipant[] = [] + const add = (label: string, address: Address | undefined) => { + if ( + !address + || participants.some(participant => participant.address.toLowerCase() === address.toLowerCase()) + ) return + participants.push({ label, address }) + } + + if (event.category === 'liquidations') { + add('Liquidator', event.actor) + add('Violator', event.counterparty) + return participants + } + + if (event.category === 'governance') { + add('Actor', event.actor) + add('Target', event.counterparty) + return participants + } + + const user = event.account ?? event.owner ?? event.actor + add('User', user) + add('Actor', event.actor) + add('Counterparty', event.counterparty) + return participants +} + +export const formatActivityValuation = ( + valuation: ActivityValuation | undefined, +): string | null => { + if (!valuation) return null + if (valuation.status === 'unavailable') return 'USD value unavailable' + if (valuation.amountUsd === undefined) { + return valuation.status === 'partial' ? 'Partial USD valuation' : null + } + const amount = formatCompactUsdValue(valuation.amountUsd) + return valuation.status === 'partial' ? `${amount} (partial)` : amount +} diff --git a/utils/sdk-query-cache.ts b/utils/sdk-query-cache.ts index a5a42a764..787f4c4dc 100644 --- a/utils/sdk-query-cache.ts +++ b/utils/sdk-query-cache.ts @@ -32,6 +32,16 @@ type SdkQueryRecorderWindow = Window & { const failureCache = new Map() +type SdkQueryInvalidationListener = (queryNames: ReadonlySet) => void +const sdkQueryInvalidationListeners = new Set() + +export const subscribeToSdkQueryInvalidations = ( + listener: SdkQueryInvalidationListener, +) => { + sdkQueryInvalidationListeners.add(listener) + return () => sdkQueryInvalidationListeners.delete(listener) +} + const buildSdkQuery = (staleTimes: Partial>): BuildQueryFn => { return ((queryName: string, fn, _target: object, context) => { const wrapped = (async (...args: Parameters) => { @@ -140,12 +150,22 @@ export const invalidateSdkQueries = (queryNames: EulerSDKQueryName[]) => { const [, queryName] = JSON.parse(key) as [string, string, string] if (names.has(queryName)) failureCache.delete(key) } - return sdkQueryClient.invalidateQueries({ + const invalidation = sdkQueryClient.invalidateQueries({ predicate: query => query.queryKey[0] === 'sdk' && typeof query.queryKey[1] === 'string' && names.has(query.queryKey[1]), }) + for (const listener of sdkQueryInvalidationListeners) { + try { + listener(names) + } + catch { + // Query invalidation is a global transaction side effect. A display + // subscriber must not be able to interrupt the remaining invalidation. + } + } + return invalidation } export const clearSdkQueryFailureCacheForTest = () => { diff --git a/utils/sdk-query-policy.ts b/utils/sdk-query-policy.ts index b45e303b9..7727375aa 100644 --- a/utils/sdk-query-policy.ts +++ b/utils/sdk-query-policy.ts @@ -55,6 +55,8 @@ export interface SdkQueryPolicyEntry { const SECOND = 1_000 const MINUTE = 60 * SECOND +export const ACTIVITY_QUERY_STALE_TIME_MS = MINUTE + export const DEFAULT_STALE_TIME_MS = 5 * MINUTE export const SDK_QUERY_POLICY: Partial> = { @@ -107,6 +109,10 @@ export const SDK_QUERY_POLICY: Partial Date: Mon, 13 Jul 2026 15:21:34 +0100 Subject: [PATCH 02/81] fix: make vault activity coverage honest - keep historical EVK categories independent of current borrowability - distinguish partial coverage and bound proxy backoffs --- components/entities/activity/ActivityFeed.vue | 7 ++++ .../overview/VaultOverviewBlockActivity.vue | 9 +---- composables/useActivityFeed.ts | 3 +- server/utils/v3-proxy-backoff.ts | 16 ++++++++ tests/composables/useActivityFeed.test.ts | 3 +- tests/server/v3-proxy-backoff.test.ts | 39 ++++++++++++++++++- tests/utils/activity-display.test.ts | 38 ++++++++++++------ utils/activity-display.ts | 11 ++++-- 8 files changed, 98 insertions(+), 28 deletions(-) diff --git a/components/entities/activity/ActivityFeed.vue b/components/entities/activity/ActivityFeed.vue index 4c9c26a55..1a9a8d236 100644 --- a/components/entities/activity/ActivityFeed.vue +++ b/components/entities/activity/ActivityFeed.vue @@ -153,6 +153,13 @@ watch(feed.hasLoaded, (hasLoaded) => { No indexed activity is available yet.
+
+ No activity is available from the indexed sources. This history may be incomplete. +
+
import type { ActivityVaultType, - EVault, VaultEntity, } from '@eulerxyz/euler-v2-sdk' import { getAddress } from 'viem' @@ -10,7 +9,6 @@ import { type ActivityFeedScope, } from '~/composables/useActivityFeed' import { getVaultActivityFilterOptions } from '~/utils/activity-display' -import { isVaultBorrowable } from '~/utils/vault/classification' const props = withDefaults(defineProps<{ vault: VaultEntity @@ -36,12 +34,7 @@ const feedScope = computed(() => ({ vaultType: props.vaultType, })) const feedContextKey = computed(() => buildActivityFeedContextKey(feedScope.value, [])) -const isBorrowable = computed(() => - props.vaultType === 'evk' && isVaultBorrowable(props.vault as EVault), -) -const categoryOptions = computed(() => getVaultActivityFilterOptions(props.vaultType, { - borrowable: isBorrowable.value, -})) +const categoryOptions = computed(() => getVaultActivityFilterOptions(props.vaultType)) const setRuntimeUnsupported = (unsupported: boolean) => { isRuntimeUnsupported.value = unsupported diff --git a/composables/useActivityFeed.ts b/composables/useActivityFeed.ts index f482a8775..c66004851 100644 --- a/composables/useActivityFeed.ts +++ b/composables/useActivityFeed.ts @@ -99,8 +99,7 @@ export const useActivityFeed = ({ const isEmpty = computed(() => hasLoaded.value && !error.value - && !isUnsupported.value - && !isSyncing.value + && coverage.value?.status === 'complete' && events.value.length === 0, ) diff --git a/server/utils/v3-proxy-backoff.ts b/server/utils/v3-proxy-backoff.ts index ed741f546..5ff490c60 100644 --- a/server/utils/v3-proxy-backoff.ts +++ b/server/utils/v3-proxy-backoff.ts @@ -1,4 +1,5 @@ export const V3_PROXY_FAILURE_BACKOFF_MS = 10_000 +export const V3_PROXY_MAX_BACKOFF_ENTRIES = 256 const RETRYABLE_V3_PROXY_STATUSES = new Set([429, 500, 502, 503, 504]) @@ -8,6 +9,12 @@ type V3ProxyBackoffEntry = { const backoffs = new Map() +const pruneV3ProxyBackoffs = (now: number) => { + for (const [key, entry] of backoffs) { + if (entry.until <= now) backoffs.delete(key) + } +} + const normalizeV3ProxyBackoffPath = (pathname: string) => { if (/^\/v3\/accounts\/[^/]+\/positions$/.test(pathname)) { return '/v3/accounts/:address/positions' @@ -101,6 +108,13 @@ export const recordV3ProxyBackoff = ( key: string, now = Date.now(), ) => { + pruneV3ProxyBackoffs(now) + backoffs.delete(key) + while (backoffs.size >= V3_PROXY_MAX_BACKOFF_ENTRIES) { + const oldestKey = backoffs.keys().next().value + if (oldestKey === undefined) break + backoffs.delete(oldestKey) + } backoffs.set(key, { until: now + V3_PROXY_FAILURE_BACKOFF_MS }) } @@ -121,3 +135,5 @@ export const updateV3ProxyBackoffFromResponse = ( export const resetV3ProxyBackoffsForTest = () => { backoffs.clear() } + +export const getV3ProxyBackoffCountForTest = () => backoffs.size diff --git a/tests/composables/useActivityFeed.test.ts b/tests/composables/useActivityFeed.test.ts index c49c17523..2088a1030 100644 --- a/tests/composables/useActivityFeed.test.ts +++ b/tests/composables/useActivityFeed.test.ts @@ -407,7 +407,7 @@ describe('useActivityFeed', () => { it('distinguishes empty, partial, syncing and unsupported coverage', async () => { const fetchVaultActivityEvents = vi.fn() .mockResolvedValueOnce(page([], { status: 'complete' })) - .mockResolvedValueOnce(page([event('partial')], { status: 'partial' })) + .mockResolvedValueOnce(page([], { status: 'partial' })) .mockResolvedValueOnce(page([], { status: 'syncing' })) .mockResolvedValueOnce(page([], { status: 'unsupported' })) const { useActivityFeed } = await setup(fetchVaultActivityEvents) @@ -424,6 +424,7 @@ describe('useActivityFeed', () => { await vi.waitFor(() => expect(feed?.isEmpty.value).toBe(true)) await feed?.refresh() expect(feed?.isPartial.value).toBe(true) + expect(feed?.isEmpty.value).toBe(false) await feed?.refresh() expect(feed?.isSyncing.value).toBe(true) expect(feed?.isEmpty.value).toBe(false) diff --git a/tests/server/v3-proxy-backoff.test.ts b/tests/server/v3-proxy-backoff.test.ts index 70806adeb..b07a9b44f 100644 --- a/tests/server/v3-proxy-backoff.test.ts +++ b/tests/server/v3-proxy-backoff.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from 'vitest' -import { buildV3ProxyBackoffKey } from '~/server/utils/v3-proxy-backoff' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildV3ProxyBackoffKey, + getV3ProxyBackoffCountForTest, + readV3ProxyBackoffMs, + recordV3ProxyBackoff, + resetV3ProxyBackoffsForTest, + V3_PROXY_FAILURE_BACKOFF_MS, + V3_PROXY_MAX_BACKOFF_ENTRIES, +} from '~/server/utils/v3-proxy-backoff' const ACCOUNT = '0x0000000000000000000000000000000000000001' const OTHER_ACCOUNT = '0x0000000000000000000000000000000000000002' @@ -7,6 +15,8 @@ const VAULT = '0x0000000000000000000000000000000000000003' const OTHER_VAULT = '0x0000000000000000000000000000000000000004' describe('activity V3 proxy backoff keys', () => { + afterEach(() => resetV3ProxyBackoffsForTest()) + it('does not key account cooldowns by wallet address', () => { const params = new URLSearchParams({ chainId: '1', @@ -72,4 +82,29 @@ describe('activity V3 proxy backoff keys', () => { expect(first).not.toBe(otherChain) expect(first).not.toContain(VAULT) }) + + it('prunes expired entries whenever a backoff is recorded', () => { + recordV3ProxyBackoff('expired-one', 0) + recordV3ProxyBackoff('expired-two', 1) + + recordV3ProxyBackoff('current', V3_PROXY_FAILURE_BACKOFF_MS + 1) + + expect(getV3ProxyBackoffCountForTest()).toBe(1) + expect(readV3ProxyBackoffMs('current', V3_PROXY_FAILURE_BACKOFF_MS + 1)).toBe( + V3_PROXY_FAILURE_BACKOFF_MS, + ) + }) + + it('bounds active backoffs and evicts the oldest recorded key', () => { + for (let index = 0; index <= V3_PROXY_MAX_BACKOFF_ENTRIES; index++) { + recordV3ProxyBackoff(`key-${index}`, 0) + } + + expect(getV3ProxyBackoffCountForTest()).toBe(V3_PROXY_MAX_BACKOFF_ENTRIES) + expect(readV3ProxyBackoffMs('key-0', 0)).toBe(0) + expect(readV3ProxyBackoffMs('key-1', 0)).toBe(V3_PROXY_FAILURE_BACKOFF_MS) + expect(readV3ProxyBackoffMs(`key-${V3_PROXY_MAX_BACKOFF_ENTRIES}`, 0)).toBe( + V3_PROXY_FAILURE_BACKOFF_MS, + ) + }) }) diff --git a/tests/utils/activity-display.test.ts b/tests/utils/activity-display.test.ts index 9a9244221..533d19547 100644 --- a/tests/utils/activity-display.test.ts +++ b/tests/utils/activity-display.test.ts @@ -14,34 +14,50 @@ import { isActivityScopeUnsupported, resolveActivityFilterCategories, } from '~/utils/activity-display' +import { isVaultBorrowable } from '~/utils/vault/classification' const ASSET = '0x0000000000000000000000000000000000000001' as const const VAULT = '0x0000000000000000000000000000000000000002' as const const SHARES = '0x0000000000000000000000000000000000000003' as const describe('activity display helpers', () => { - it('returns vault-specific category filters', () => { + it('returns vault-specific category filters with category-accurate labels', () => { expect(getVaultActivityFilterOptions('evk')).toEqual([ - { value: 'user-operations', label: 'User operations', categories: ['lending', 'borrowing'] }, + { value: 'lending-borrowing', label: 'Lending and borrowing', categories: ['lending', 'borrowing'] }, { value: 'governance', label: 'Governance', categories: ['governance'] }, { value: 'liquidations', label: 'Liquidations', categories: ['liquidations'] }, ]) - expect(getVaultActivityFilterOptions('evk', { borrowable: false })).toEqual([ - { value: 'user-operations', label: 'User operations', categories: ['lending'] }, - { value: 'governance', label: 'Governance', categories: ['governance'] }, - ]) expect(getVaultActivityFilterOptions('earn')).toEqual([ - { value: 'user-operations', label: 'User operations', categories: ['lending'] }, + { value: 'lending', label: 'Lending', categories: ['lending'] }, { value: 'governance', label: 'Governance', categories: ['governance'] }, ]) expect(getVaultActivityFilterOptions('securitize')).toEqual([ - { value: 'user-operations', label: 'User operations', categories: ['lending'] }, + { value: 'lending', label: 'Lending', categories: ['lending'] }, { value: 'governance', label: 'Governance', categories: ['governance'] }, ]) - const options = getVaultActivityFilterOptions('evk', { borrowable: false }) - expect(resolveActivityFilterCategories(options, [])).toEqual(['governance', 'lending']) - expect(resolveActivityFilterCategories(options, ['user-operations'])).toEqual(['lending']) + const options = getVaultActivityFilterOptions('evk') + expect(resolveActivityFilterCategories(options, [])).toEqual([ + 'borrowing', + 'governance', + 'lending', + 'liquidations', + ]) + expect(resolveActivityFilterCategories(options, ['lending-borrowing'])).toEqual([ + 'borrowing', + 'lending', + ]) + }) + + it('keeps historical borrowing filters for a currently non-borrowable EVK', () => { + expect(isVaultBorrowable({ isBorrowable: false, totalBorrowed: 0n })).toBe(false) + + expect(resolveActivityFilterCategories(getVaultActivityFilterOptions('evk'), [])).toEqual([ + 'borrowing', + 'governance', + 'lending', + 'liquidations', + ]) }) it('treats only explicit-All unsupported coverage as scope-wide', () => { diff --git a/utils/activity-display.ts b/utils/activity-display.ts index 30934b1e8..6196a141a 100644 --- a/utils/activity-display.ts +++ b/utils/activity-display.ts @@ -79,16 +79,19 @@ export const getActivityCategoryIcon = (category: ActivityCategory): string => export const getVaultActivityFilterOptions = ( vaultType: ActivityVaultType, - { borrowable = true }: { borrowable?: boolean } = {}, ): ActivityFilterOption[] => { - const userCategories: ActivityCategory[] = vaultType === 'evk' && borrowable + const lendingCategories: ActivityCategory[] = vaultType === 'evk' ? ['lending', 'borrowing'] : ['lending'] const options: ActivityFilterOption[] = [ - { value: 'user-operations', label: 'User operations', categories: userCategories }, + { + value: vaultType === 'evk' ? 'lending-borrowing' : 'lending', + label: vaultType === 'evk' ? 'Lending and borrowing' : 'Lending', + categories: lendingCategories, + }, { value: 'governance', label: 'Governance', categories: ['governance'] }, ] - if (vaultType === 'evk' && borrowable) { + if (vaultType === 'evk') { options.push({ value: 'liquidations', label: 'Liquidations', categories: ['liquidations'] }) } return options From d640f9252c333642f74c101e61377064c0c0971a Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:31:04 +0100 Subject: [PATCH 03/81] fix: filter activity display events Keep raw V3 activity available while Lite requests only scope-relevant display events and suppresses accrual and paired share-movement noise. --- composables/useActivityFeed.ts | 11 +- tests/composables/useActivityFeed.test.ts | 49 ++++++- tests/utils/activity-display.test.ts | 28 ++++ utils/activity-display.ts | 149 ++++++++++++++++++++++ 4 files changed, 231 insertions(+), 6 deletions(-) diff --git a/composables/useActivityFeed.ts b/composables/useActivityFeed.ts index c66004851..5c84538cc 100644 --- a/composables/useActivityFeed.ts +++ b/composables/useActivityFeed.ts @@ -17,6 +17,10 @@ import { } from 'vue' import { subscribeToSdkQueryInvalidations } from '~/utils/sdk-query-cache' import { ACTIVITY_QUERY_STALE_TIME_MS } from '~/utils/sdk-query-policy' +import { + filterActivityEventsForDisplay, + getDisplayActivityEventTypes, +} from '~/utils/activity-display' export type ActivityFeedScope = | { kind: 'account', owner: Address, chainId: number | readonly number[] } @@ -153,9 +157,11 @@ export const useActivityFeed = ({ try { const { getEulerSdkForChain } = useEulerSdk() const sdk = await getEulerSdkForChain(scopeSdkChainId(requestScope)) + const eventTypes = getDisplayActivityEventTypes(requestScope) const common = { ...(requestCategories.length ? { categories: requestCategories } : {}), ...(cursor ? { cursor } : {}), + eventTypes, limit, } const page = requestScope.kind === 'account' @@ -176,9 +182,10 @@ export const useActivityFeed = ({ throw new Error('Activity pagination cursor did not advance') } + const displayEvents = filterActivityEventsForDisplay(page.data, eventTypes) events.value = mode === 'append' - ? mergeActivityEvents(events.value, page.data) - : mergeActivityEvents([], page.data) + ? mergeActivityEvents(events.value, displayEvents) + : mergeActivityEvents([], displayEvents) meta.value = page.meta error.value = undefined loadMoreError.value = undefined diff --git a/tests/composables/useActivityFeed.test.ts b/tests/composables/useActivityFeed.test.ts index 2088a1030..1e3a5d905 100644 --- a/tests/composables/useActivityFeed.test.ts +++ b/tests/composables/useActivityFeed.test.ts @@ -6,11 +6,15 @@ const VAULT = '0x0000000000000000000000000000000000000001' as const const OTHER_VAULT = '0x0000000000000000000000000000000000000002' as const const TX_HASH = `0x${'1'.repeat(64)}` as const -const event = (id: string, vault: typeof VAULT | typeof OTHER_VAULT = VAULT) => ({ +const event = ( + id: string, + vault: typeof VAULT | typeof OTHER_VAULT = VAULT, + type = 'deposit', +) => ({ id, chainId: 1, - type: 'deposit', - rawType: 'deposit', + type, + rawType: type === 'mint' || type === 'burn' ? 'transfer' : type, category: 'lending' as const, timestamp: '2026-07-13T10:30:00.000Z', blockNumber: '123', @@ -76,7 +80,7 @@ describe('useActivityFeed', () => { }) it('does not request activity until enabled and forwards server-side categories', async () => { - const fetchVaultActivityEvents = vi.fn(async () => page([event('one')])) + const fetchVaultActivityEvents = vi.fn(async (_args: { eventTypes?: readonly string[] }) => page([event('one')])) const { useActivityFeed } = await setup(fetchVaultActivityEvents) const enabled = ref(false) const categories = ref(['lending', 'governance'] as const) @@ -99,8 +103,45 @@ describe('useActivityFeed', () => { chainId: 1, vaultType: 'evk', categories: ['governance', 'lending'], + eventTypes: expect.any(Array), limit: 25, }) + const requestedEventTypes = fetchVaultActivityEvents.mock.calls[0]?.[0]?.eventTypes + expect(requestedEventTypes).toEqual(expect.arrayContaining(['deposit', 'withdraw', 'transfer'])) + expect(requestedEventTypes).not.toEqual(expect.arrayContaining([ + 'interest_accrued', + 'accrue_interest', + 'mint', + 'burn', + ])) + }) + + it('keeps display events while dropping interest accrual and paired share movements', async () => { + const fetchVaultActivityEvents = vi.fn(async () => page([ + event('deposit'), + event('mint-shadow', VAULT, 'mint'), + event('evk-interest', VAULT, 'interest_accrued'), + event('earn-interest', VAULT, 'accrue_interest'), + event('transfer', VAULT, 'transfer'), + event('withdraw', VAULT, 'withdraw'), + event('burn-shadow', VAULT, 'burn'), + ])) + const { useActivityFeed } = await setup(fetchVaultActivityEvents) + let feed: ReturnType | undefined + effect = effectScope() + effect.run(() => { + feed = useActivityFeed({ + scope: { kind: 'vault', vault: VAULT, chainId: 1, vaultType: 'evk' }, + enabled: true, + categories: [], + }) + }) + + await vi.waitFor(() => expect(feed?.events.value.map(item => item.id)).toEqual([ + 'deposit', + 'transfer', + 'withdraw', + ])) }) it('retains loaded rows without refetching when collapsed and reopened', async () => { diff --git a/tests/utils/activity-display.test.ts b/tests/utils/activity-display.test.ts index 533d19547..fe6aa0a26 100644 --- a/tests/utils/activity-display.test.ts +++ b/tests/utils/activity-display.test.ts @@ -10,6 +10,7 @@ import { getActivityAssetLabel, getActivityChangeEntries, getActivityParticipants, + getDisplayActivityEventTypes, getVaultActivityFilterOptions, isActivityScopeUnsupported, resolveActivityFilterCategories, @@ -21,6 +22,33 @@ const VAULT = '0x0000000000000000000000000000000000000002' as const const SHARES = '0x0000000000000000000000000000000000000003' as const describe('activity display helpers', () => { + it('uses bounded scope-specific event filters without display noise', () => { + const scopes = [ + { kind: 'account' }, + { kind: 'vault', vaultType: 'evk' }, + { kind: 'vault', vaultType: 'earn' }, + { kind: 'vault', vaultType: 'securitize' }, + ] as const + const hiddenTypes = ['interest_accrued', 'accrue_interest', 'mint', 'burn'] + + for (const scope of scopes) { + const eventTypes = getDisplayActivityEventTypes(scope) + expect(eventTypes.join(',').length).toBeLessThanOrEqual(1_024) + expect(eventTypes).not.toEqual(expect.arrayContaining(hiddenTypes)) + } + + expect(getDisplayActivityEventTypes({ kind: 'account' })).toEqual(expect.arrayContaining([ + 'deposit', + 'transfer', + 'reward_transfer', + ])) + expect(getDisplayActivityEventTypes({ kind: 'vault', vaultType: 'evk' })).toEqual(expect.arrayContaining([ + 'deposit', + 'transfer', + 'set_caps', + ])) + }) + it('returns vault-specific category filters with category-accurate labels', () => { expect(getVaultActivityFilterOptions('evk')).toEqual([ { value: 'lending-borrowing', label: 'Lending and borrowing', categories: ['lending', 'borrowing'] }, diff --git a/utils/activity-display.ts b/utils/activity-display.ts index 6196a141a..7e11a2aaa 100644 --- a/utils/activity-display.ts +++ b/utils/activity-display.ts @@ -5,6 +5,7 @@ import type { ActivityChange, ActivityChangeValue, ActivityCoverageStatus, + ActivityEvent, ActivityValuation, ActivityVaultType, } from '@eulerxyz/euler-v2-sdk' @@ -39,6 +40,154 @@ const CATEGORY_LABELS: Record = { governance: 'Governance', } +const ACCOUNT_ACTIVITY_EVENT_TYPES = [ + 'deposit', + 'withdraw', + 'transfer', + 'borrow', + 'repay', + 'swap', + 'debt_socialized', + 'pull_debt', + 'liquidation', + 'approval', + 'balance_forwarder_status', + 'convert_fees', + 'reallocate_supply', + 'reallocate_withdraw', + 'set_fee', + 'set_guardian', + 'set_timelock', + 'set_cap', + 'set_supply_queue', + 'set_withdraw_queue', + 'submit_cap', + 'submit_market_removal', + 'revoke_pending_cap', + 'revoke_pending_guardian', + 'revoke_pending_timelock', + 'revoke_pending_market_removal', + 'frozen', + 'unfrozen', + 'seized', + 'owner_registered', + 'collateral_status', + 'operator_status', + 'controller_status', + 'lockdown_mode_status', + 'nonce_status', + 'nonce_used', + 'permit_disabled_mode_status', + 'reward_lock_created', + 'reward_lock_removed', + 'reward_transfer', + 'reward_whitelist_status', + 'public_reallocate_to', + 'public_withdrawal', + 'set_admin', + 'set_allocation_fee', + 'set_flow_caps', + 'transfer_allocation_fee', + 'buy', + 'terms_of_use_signed', +] as const satisfies readonly ActivityEvent['type'][] + +const VAULT_ACTIVITY_EVENT_TYPES = { + evk: [ + 'deposit', + 'withdraw', + 'transfer', + 'borrow', + 'repay', + 'debt_socialized', + 'pull_debt', + 'liquidation', + 'approval', + 'balance_forwarder_status', + 'convert_fees', + 'set_caps', + 'set_ltv', + 'set_governor_admin', + 'set_config_flags', + 'set_fee_receiver', + 'set_hook_config', + 'set_interest_fee', + 'set_interest_rate_model', + 'set_liquidation_cool_off_time', + 'set_max_liquidation_discount', + ], + earn: [ + 'deposit', + 'withdraw', + 'transfer', + 'update_last_total_assets', + 'update_lost_assets', + 'approval', + 'reallocate_supply', + 'reallocate_withdraw', + 'set_name', + 'set_symbol', + 'set_fee', + 'set_fee_recipient', + 'set_curator', + 'set_guardian', + 'set_timelock', + 'set_cap', + 'set_is_allocator', + 'set_supply_queue', + 'set_withdraw_queue', + 'submit_cap', + 'submit_guardian', + 'submit_timelock', + 'submit_market_removal', + 'revoke_pending_cap', + 'revoke_pending_guardian', + 'revoke_pending_timelock', + 'revoke_pending_market_removal', + 'ownership_transfer_started', + 'ownership_transferred', + 'public_reallocate_to', + 'public_withdrawal', + 'set_admin', + 'set_allocation_fee', + 'set_flow_caps', + 'transfer_allocation_fee', + ], + securitize: [ + 'deposit', + 'withdraw', + 'transfer', + 'approval', + 'seized', + 'frozen', + 'unfrozen', + 'paused', + 'unpaused', + 'set_controller_perspective', + 'set_governor_admin', + 'set_supply_cap', + ], +} as const satisfies Record + +type ActivityDisplayScope + = | { kind: 'account' } + | { kind: 'vault', vaultType: ActivityVaultType } + +export const getDisplayActivityEventTypes = ( + scope: ActivityDisplayScope, +): readonly ActivityEvent['type'][] => + scope.kind === 'account' + ? ACCOUNT_ACTIVITY_EVENT_TYPES + : VAULT_ACTIVITY_EVENT_TYPES[scope.vaultType] + +export const filterActivityEventsForDisplay = >( + events: readonly T[], + eventTypes: readonly ActivityEvent['type'][], +): T[] => { + const displayEventTypes = new Set(eventTypes) + return events.filter(event => displayEventTypes.has(event.type)) +} + export interface ActivityFilterOption { value: string label: string From 9832063a2d0e98f66fc566711d1576e3436ed0ce Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:37:22 +0100 Subject: [PATCH 04/81] fix: improve activity event presentation - clarify transfers and suppress exact paired share movements - add token imagery, compact responsive details, and copyable addresses - hide unavailable pricing noise while preserving truthful fallbacks --- .../entities/activity/ActivityAddress.vue | 63 +++++ .../entities/activity/ActivityEventRow.vue | 246 +++++++++++------- components/entities/activity/ActivityFeed.vue | 6 +- composables/useActivityFeed.ts | 8 +- tests/composables/useActivityFeed.test.ts | 54 +++- tests/utils/activity-display.test.ts | 99 ++++++- utils/activity-display.ts | 117 +++++++-- 7 files changed, 464 insertions(+), 129 deletions(-) create mode 100644 components/entities/activity/ActivityAddress.vue diff --git a/components/entities/activity/ActivityAddress.vue b/components/entities/activity/ActivityAddress.vue new file mode 100644 index 000000000..1caa2c806 --- /dev/null +++ b/components/entities/activity/ActivityAddress.vue @@ -0,0 +1,63 @@ + + + diff --git a/components/entities/activity/ActivityEventRow.vue b/components/entities/activity/ActivityEventRow.vue index 129f828ae..1112912b7 100644 --- a/components/entities/activity/ActivityEventRow.vue +++ b/components/entities/activity/ActivityEventRow.vue @@ -1,75 +1,125 @@ + + From 26b4581b50fb00a825cd55354514ca0bf7697428 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:55:19 +0100 Subject: [PATCH 07/81] fix: tighten compact activity rows Group participant metadata with the event summary at constrained feed widths so vault activity stays dense while preserving the wide portfolio table. --- .../entities/activity/ActivityEventRow.vue | 189 ++++++++++++------ 1 file changed, 132 insertions(+), 57 deletions(-) diff --git a/components/entities/activity/ActivityEventRow.vue b/components/entities/activity/ActivityEventRow.vue index 5e646def4..512e7e325 100644 --- a/components/entities/activity/ActivityEventRow.vue +++ b/components/entities/activity/ActivityEventRow.vue @@ -107,30 +107,65 @@ const transactionLink = computed(() => getExplorerLink(event.txHash, event.chain From 38119cb62685e727fd04f9a232649925b89dbb36 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:46:50 +0100 Subject: [PATCH 28/81] fix: align activity rows on a single centerline Apply one alignment rule to every mid-width row: event summary, details, and the transaction link share the first grid row and center against each other, while participants span a full-width second row. Replaces the static-row special case that left lending amounts floating between the title and participant lines. --- .../entities/activity/ActivityEventRow.vue | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/components/entities/activity/ActivityEventRow.vue b/components/entities/activity/ActivityEventRow.vue index 19fc11637..ac4497575 100644 --- a/components/entities/activity/ActivityEventRow.vue +++ b/components/entities/activity/ActivityEventRow.vue @@ -195,10 +195,7 @@ const vaultDisplay = computed(() => {