{
grid-template-columns: 32px minmax(0, 1fr);
}
+.activity-event-row--portfolio {
+ grid-template-columns: minmax(0, 1fr);
+}
+
.activity-event-row--grouped {
border-radius: 0;
padding-top: 10px;
padding-bottom: 10px;
}
+.activity-event-row--portfolio .activity-event-row__event-summary {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ column-gap: 6px;
+}
+
+.activity-event-row--portfolio .activity-event-row__event-summary--with-vault,
+.activity-event-row--portfolio .activity-event-row__details--with-vault {
+ padding-left: 50px;
+}
+
+.activity-event-row--portfolio .activity-event-row__meta {
+ margin-top: 0;
+ column-gap: 6px;
+}
+
.activity-event-row__details,
.activity-event-row__participants {
grid-column: 1 / -1;
@@ -567,6 +606,23 @@ const vaultDisplay = computed(() => {
justify-self: end;
}
+ .activity-event-row--portfolio {
+ grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.2fr) 40px;
+ }
+
+ .activity-event-row--portfolio .activity-event-row__title {
+ grid-column: 1;
+ }
+
+ .activity-event-row--portfolio .activity-event-row__details {
+ grid-column: 2;
+ padding-left: 0;
+ }
+
+ .activity-event-row--portfolio .activity-event-row__transaction {
+ grid-column: 3;
+ }
+
.activity-event-row__asset-label,
.activity-event-row__asset-address-kind {
display: none;
@@ -590,6 +646,13 @@ const vaultDisplay = computed(() => {
44px;
}
+ .activity-event-row--portfolio {
+ grid-template-columns:
+ minmax(280px, 1fr)
+ minmax(320px, 1.2fr)
+ 44px;
+ }
+
.activity-event-row__secondary-detail {
display: block;
}
From dc71453ac7f3b5d3ecb9b991f5d8d9a78685d4fc Mon Sep 17 00:00:00 2001
From: Seranged <80223622+Seranged@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:32:00 +0100
Subject: [PATCH 43/81] fix: never display sub-account addresses in activity
participants
Funds sent to a sub-account are unrecoverable, so participant addresses
must always be the owner wallet. Account-scope events substitute the
owner from event metadata; vault-scope events carry no owner, so
participants stay hidden until a cached EVC getAccountOwner lookup
settles and sub-accounts render as their resolved owner.
---
.../entities/activity/ActivityEventRow.vue | 23 +++++-
composables/useEvcAccountOwners.ts | 70 +++++++++++++++++++
tests/utils/activity-display.test.ts | 31 ++++++++
utils/activity-display.ts | 13 +++-
4 files changed, 135 insertions(+), 2 deletions(-)
create mode 100644 composables/useEvcAccountOwners.ts
diff --git a/components/entities/activity/ActivityEventRow.vue b/components/entities/activity/ActivityEventRow.vue
index 3113993f0..33e22730e 100644
--- a/components/entities/activity/ActivityEventRow.vue
+++ b/components/entities/activity/ActivityEventRow.vue
@@ -179,6 +179,11 @@ const details = computed(() => [
const portfolioPosition = computed(() => showVault
? getPortfolioActivityPositionParticipant(event)
: null)
+const { requestOwner, getResolvedOwner } = useEvcAccountOwners()
+const rawParticipants = computed(() => showVault ? [] : getActivityParticipants(event))
+watch(() => rawParticipants.value.map(participant => participant.address), (addresses) => {
+ for (const address of addresses) requestOwner(event.chainId, address)
+}, { immediate: true })
const participants = computed(() => {
// Re-resolve participant vault names when registry metadata arrives.
void registryVersion.value
@@ -190,8 +195,24 @@ const participants = computed(() => {
}
const viewer = viewerAddress?.toLowerCase()
- return getActivityParticipants(event)
+ const seenAddresses = new Set()
+ return rawParticipants.value
+ .map((participant) => {
+ // Vault-scope events don't say whether an account is a sub-account, so
+ // every address stays hidden until the EVC owner lookup settles —
+ // sub-accounts render as their owner wallet, never as themselves.
+ const resolvedOwner = getResolvedOwner(event.chainId, participant.address)
+ if (resolvedOwner === undefined) return null
+ return { ...participant, address: resolvedOwner ?? participant.address }
+ })
+ .filter((participant): participant is NonNullable => participant !== null)
.filter(participant => participant.address.toLowerCase() !== viewer)
+ .filter((participant) => {
+ const key = participant.address.toLowerCase()
+ if (seenAddresses.has(key)) return false
+ seenAddresses.add(key)
+ return true
+ })
.map((participant) => {
// Known vaults (e.g. Earn vaults acting on underlying markets) read
// better as named vault links than as spy-mode "User 0x…" addresses.
diff --git a/composables/useEvcAccountOwners.ts b/composables/useEvcAccountOwners.ts
new file mode 100644
index 000000000..486605738
--- /dev/null
+++ b/composables/useEvcAccountOwners.ts
@@ -0,0 +1,70 @@
+import { getAddress, isAddress, type Address } from 'viem'
+import { reactive } from 'vue'
+import { evcGetAccountOwnerAbi } from '~/abis/evc'
+import { logWarn } from '~/utils/errorHandling'
+
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
+
+/**
+ * Chain-scoped EVC account-owner cache.
+ *
+ * Sub-account addresses must never be displayed in the app — funds sent to
+ * one are unrecoverable — and vault-scope activity events carry no owner
+ * metadata, so resolving through the EVC is the only reliable way to tell a
+ * wallet apart from a sub-account.
+ *
+ * Cache semantics: no entry = unknown/pending (keep the address hidden),
+ * `null` = resolved as safe to display as-is (the address is its own owner or
+ * was never EVC-registered), `Address` = registered owner to display instead.
+ */
+const owners = reactive(new Map())
+const pending = new Set()
+
+const cacheKey = (chainId: number, address: string) => `${chainId}:${address.toLowerCase()}`
+
+export const useEvcAccountOwners = () => {
+ const { eulerCoreAddresses, chainId: activeChainId } = useEulerAddresses()
+ const { client: rpcClient } = useRpcClient()
+
+ const requestOwner = (chainId: number, address: string) => {
+ const normalized = address.toLowerCase() as `0x${string}`
+ if (!isAddress(normalized)) return
+ const key = cacheKey(chainId, address)
+ if (owners.has(key) || pending.has(key)) return
+
+ const evcAddress = eulerCoreAddresses.value?.evc
+ const client = rpcClient.value
+ if (!evcAddress || !client || Number(activeChainId.value) !== chainId) return
+
+ pending.add(key)
+ client.readContract({
+ address: evcAddress as Address,
+ abi: evcGetAccountOwnerAbi,
+ functionName: 'getAccountOwner',
+ authorizationList: undefined,
+ args: [getAddress(normalized)],
+ })
+ .then((owner) => {
+ owners.set(
+ key,
+ owner && owner !== ZERO_ADDRESS && getAddress(owner) !== getAddress(normalized)
+ ? getAddress(owner)
+ : null,
+ )
+ })
+ .catch((err) => {
+ // Fail closed: the address stays hidden, and the next request retries.
+ logWarn('useEvcAccountOwners/requestOwner', err)
+ })
+ .finally(() => pending.delete(key))
+ }
+
+ /**
+ * `undefined` = still resolving (hide the address), `null` = safe to show
+ * as-is, `Address` = show this owner wallet instead.
+ */
+ const getResolvedOwner = (chainId: number, address: string): Address | null | undefined =>
+ owners.get(cacheKey(chainId, address))
+
+ return { requestOwner, getResolvedOwner }
+}
diff --git a/tests/utils/activity-display.test.ts b/tests/utils/activity-display.test.ts
index 49e58a4f9..a7e1c3820 100644
--- a/tests/utils/activity-display.test.ts
+++ b/tests/utils/activity-display.test.ts
@@ -638,6 +638,37 @@ describe('activity display helpers', () => {
])
})
+ it('never exposes sub-account addresses in participants', () => {
+ const owner = '0x00000000000000000000000000000000000000a0' as const
+ const subAccount = '0x00000000000000000000000000000000000000a7' as const
+
+ expect(getActivityParticipants({
+ category: 'lending',
+ owner,
+ account: subAccount,
+ subAccountIndex: 7,
+ })).toEqual([{ label: 'User', address: owner, linkKind: 'spy' }])
+
+ expect(getActivityParticipants({
+ category: 'liquidations',
+ owner,
+ account: subAccount,
+ subAccountIndex: 7,
+ actor: ASSET,
+ counterparty: subAccount,
+ })).toEqual([
+ { label: 'Liquidator', address: ASSET, linkKind: 'explorer' },
+ { label: 'Violator', address: owner, linkKind: 'spy' },
+ ])
+
+ // Without a known owner the sub-account participant is dropped entirely.
+ expect(getActivityParticipants({
+ category: 'lending',
+ account: subAccount,
+ subAccountIndex: 7,
+ })).toEqual([])
+ })
+
it('represents a viewed liquidated subaccount only as an internal position', () => {
expect(getPortfolioActivityPositionParticipant({
account: VAULT,
diff --git a/utils/activity-display.ts b/utils/activity-display.ts
index 3eeac1f58..4797b27a7 100644
--- a/utils/activity-display.ts
+++ b/utils/activity-display.ts
@@ -944,11 +944,22 @@ export const getActivityParticipants = (
event: ActivityParticipantSource,
): ActivityParticipant[] => {
const participants: ActivityParticipant[] = []
+ // Sub-account addresses must never be displayed (or copied) anywhere in the
+ // app — funds sent to one are unrecoverable. Substitute the owner wallet,
+ // which spy links resolve to anyway, and drop the participant when the
+ // owner is unknown.
+ const isSubAccountAddress = (address: Address) =>
+ (event.subAccountIndex ?? 0) !== 0
+ && event.account !== undefined
+ && address.toLowerCase() === event.account.toLowerCase()
const add = (
label: string,
- address: Address | undefined,
+ rawAddress: Address | undefined,
linkKind: ActivityParticipant['linkKind'],
) => {
+ const address = rawAddress && isSubAccountAddress(rawAddress)
+ ? event.owner
+ : rawAddress
if (
!address
|| participants.some(participant => participant.address.toLowerCase() === address.toLowerCase())
From 78ea060d44434900df30a3b9c28a43289fd01f1e Mon Sep 17 00:00:00 2001
From: Seranged <80223622+Seranged@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:40:32 +0100
Subject: [PATCH 44/81] fix: reduce portfolio activity filters to a counted
liquidations chip
Event verbs and transaction grouping already communicate lending and
borrowing, so the portfolio chip row shrinks to All plus Liquidations.
The liquidations chip carries an event count from a dedicated bounded
query so the answer to "was I ever liquidated?" is visible without
clicking, while the unfiltered feed still queries every displayable
category.
---
.../activity/ActivityCategoryFilters.vue | 2 +-
components/entities/activity/ActivityFeed.vue | 34 +++++++++++++++++--
pages/portfolio/activity.vue | 4 ++-
tests/utils/activity-display.test.ts | 10 +++---
utils/activity-display.ts | 27 ++++++++++-----
5 files changed, 60 insertions(+), 17 deletions(-)
diff --git a/components/entities/activity/ActivityCategoryFilters.vue b/components/entities/activity/ActivityCategoryFilters.vue
index 1809a6303..35f293128 100644
--- a/components/entities/activity/ActivityCategoryFilters.vue
+++ b/components/entities/activity/ActivityCategoryFilters.vue
@@ -41,7 +41,7 @@ const toggle = (filter: string) => {
:aria-pressed="selected.includes(option.value)"
@click="toggle(option.value)"
>
- {{ option.label }}
+ {{ option.count !== undefined ? `${option.label} (${option.count})` : option.label }}
diff --git a/components/entities/activity/ActivityFeed.vue b/components/entities/activity/ActivityFeed.vue
index 00f1bc96c..073923258 100644
--- a/components/entities/activity/ActivityFeed.vue
+++ b/components/entities/activity/ActivityFeed.vue
@@ -18,6 +18,8 @@ const props = withDefaults(defineProps<{
scope: ActivityFeedScope
enabled: boolean
categoryOptions: readonly ActivityFilterOption[]
+ /** Categories queried when no filter chip is selected — defaults to the union of the option categories. */
+ unfilteredCategories?: readonly ActivityCategory[]
subject?: 'account' | 'vault'
}>(), {
subject: 'vault',
@@ -29,7 +31,9 @@ const emit = defineEmits<{
const selectedFilters = ref
([])
const selectedCategories = computed(() =>
- resolveActivityFilterCategories(props.categoryOptions, selectedFilters.value),
+ selectedFilters.value.length === 0 && props.unfilteredCategories
+ ? [...props.unfilteredCategories].sort()
+ : resolveActivityFilterCategories(props.categoryOptions, selectedFilters.value),
)
const activityNowMs = useActivityNowMs()
const scopeLabel = computed(() => props.subject === 'account' ? 'account' : 'vault')
@@ -45,6 +49,30 @@ const feed = useActivityFeed({
categories: selectedCategories,
})
+// A dedicated bounded query keeps the liquidation chip count honest — the
+// main feed only knows about the pages loaded so far.
+const LIQUIDATION_COUNT_LIMIT = 100
+const liquidationCountFeed = useActivityFeed({
+ scope: () => props.scope,
+ enabled: () => props.enabled && props.subject === 'account',
+ categories: () => ['liquidations'],
+ limit: LIQUIDATION_COUNT_LIMIT,
+})
+const liquidationCount = computed(() => {
+ if (
+ props.subject !== 'account'
+ || !liquidationCountFeed.hasLoaded.value
+ || liquidationCountFeed.error.value
+ ) return undefined
+ const count = liquidationCountFeed.events.value.length
+ return liquidationCountFeed.hasMore.value ? `${count}+` : count
+})
+const displayCategoryOptions = computed(() => props.categoryOptions.map(option =>
+ option.value === 'liquidations' && liquidationCount.value !== undefined
+ ? { ...option, count: liquidationCount.value }
+ : option,
+))
+
const missingCategoryLabels = computed(() =>
feed.coverage.value?.missingCategories
?.map(getActivityCategoryLabel)
@@ -106,9 +134,9 @@ watch(feed.hasLoaded, (hasLoaded) => {
(() => {
const availability = useActivityAvailability({ kind: 'account' }, chainId)
const runtimeSupport = usePortfolioActivityRuntimeSupport(owner, chainId)
const categoryOptions = getAccountActivityFilterOptions()
+const unfilteredCategories = getAccountActivityCategories()
const isActive = computed(() => route.name === 'portfolio-activity')
const feedScope = computed
(() => owner.value
? {
@@ -82,6 +83,7 @@ watch([owner, () => Number(chainId.value)], () => {
:scope="feedScope"
:enabled="isActive"
:category-options="categoryOptions"
+ :unfiltered-categories="unfilteredCategories"
subject="account"
@update:unsupported="runtimeSupport.setRuntimeUnsupported"
/>
diff --git a/tests/utils/activity-display.test.ts b/tests/utils/activity-display.test.ts
index a7e1c3820..8b05433df 100644
--- a/tests/utils/activity-display.test.ts
+++ b/tests/utils/activity-display.test.ts
@@ -12,6 +12,7 @@ import {
formatActivityTimestamp,
formatActivityValuation,
formatActivityValuationForAssets,
+ getAccountActivityCategories,
getAccountActivityFilterOptions,
getActivityAmountDirection,
getActivityAssetAddressLabel,
@@ -90,13 +91,14 @@ describe('activity display helpers', () => {
})
it('returns the focused portfolio position category filters in display order', () => {
- // No `account` option: none of that category's event types are displayed
- // on Lite, so the filter would always come back empty.
+ // Only liquidations is exposed as a chip — verbs and transaction grouping
+ // already communicate lending/borrowing — while the unfiltered feed still
+ // queries every displayable category. No `account` category anywhere:
+ // none of its event types are displayed on Lite.
expect(getAccountActivityFilterOptions()).toEqual([
- { value: 'lending', label: 'Lending', categories: ['lending'] },
- { value: 'borrowing', label: 'Borrowing', categories: ['borrowing'] },
{ value: 'liquidations', label: 'Liquidations', categories: ['liquidations'] },
])
+ expect(getAccountActivityCategories()).toEqual(['lending', 'borrowing', 'liquidations'])
})
it('returns vault-specific category filters with category-accurate labels', () => {
diff --git a/utils/activity-display.ts b/utils/activity-display.ts
index 4797b27a7..6241ea4ba 100644
--- a/utils/activity-display.ts
+++ b/utils/activity-display.ts
@@ -295,6 +295,8 @@ export interface ActivityFilterOption {
value: string
label: string
categories: readonly ActivityCategory[]
+ /** Optional event count rendered after the label, e.g. "Liquidations (2)". */
+ count?: number | string
}
export const resolveActivityFilterCategories = (
@@ -351,20 +353,29 @@ export const getVaultActivityFilterOptions = (
// The `account` category (controller/collateral/operator status, ToS
// signatures, …) is deliberately absent: none of its event types are
-// displayed on Lite, so offering the filter would only surface an
-// always-empty view.
+// displayed on Lite, so querying it would only surface empty results.
const ACCOUNT_ACTIVITY_CATEGORIES = [
'lending',
'borrowing',
'liquidations',
] as const satisfies readonly ActivityCategory[]
-export const getAccountActivityFilterOptions = (): ActivityFilterOption[] =>
- ACCOUNT_ACTIVITY_CATEGORIES.map(category => ({
- value: category,
- label: getActivityCategoryLabel(category),
- categories: [category],
- }))
+/** Categories the unfiltered portfolio feed queries. */
+export const getAccountActivityCategories = (): readonly ActivityCategory[] =>
+ ACCOUNT_ACTIVITY_CATEGORIES
+
+/**
+ * The portfolio chip row exposes only liquidations: event verbs and
+ * transaction grouping already communicate lending/borrowing, while "was I
+ * ever liquidated?" deserves a one-click answer.
+ */
+export const getAccountActivityFilterOptions = (): ActivityFilterOption[] => [
+ {
+ value: 'liquidations',
+ label: getActivityCategoryLabel('liquidations'),
+ categories: ['liquidations'],
+ },
+]
const applyActivityAcronyms = (label: string): string => label
.replace(/\bltv\b/gi, 'LTV')
From ec72cdc343a5377a05122f9e9d8bfe9c70a9d70a Mon Sep 17 00:00:00 2001
From: Seranged <80223622+Seranged@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:53:43 +0100
Subject: [PATCH 45/81] fix: rebalance portfolio activity row hierarchy
Add a small VaultLabelsAndAssets variant (32px avatar, compact type) so
the event verb becomes the heaviest text in portfolio rows instead of
the asset symbol, widen the event column against the mostly-short
amount column with a matching portfolio header grid, bleed transaction
group cards to the same width as row hover surfaces, and align the
group header with row text.
---
.../entities/activity/ActivityEventRow.vue | 15 ++++++++++--
components/entities/activity/ActivityFeed.vue | 23 ++++++++++++++++---
.../entities/vault/VaultLabelsAndAssets.vue | 15 ++++++++----
3 files changed, 43 insertions(+), 10 deletions(-)
diff --git a/components/entities/activity/ActivityEventRow.vue b/components/entities/activity/ActivityEventRow.vue
index 33e22730e..d2fa08000 100644
--- a/components/entities/activity/ActivityEventRow.vue
+++ b/components/entities/activity/ActivityEventRow.vue
@@ -311,6 +311,7 @@ const vaultDisplay = computed(() => {
v-if="vaultDisplay.vault"
:vault="vaultDisplay.vault"
:assets="[vaultDisplay.vault.asset]"
+ size="small"
/>
{
column-gap: 6px;
}
+/* Indent the verb and stacked details to the identity text (32px avatar +
+ 10px gap). */
.activity-event-row--portfolio .activity-event-row__event-summary--with-vault,
.activity-event-row--portfolio .activity-event-row__details--with-vault {
- padding-left: 50px;
+ padding-left: 42px;
}
.activity-event-row--portfolio .activity-event-row__meta {
@@ -640,6 +643,12 @@ const vaultDisplay = computed(() => {
padding-left: 0;
}
+ /* Details live in their own column here — the stacked-layout indent would
+ only waste width. */
+ .activity-event-row--portfolio .activity-event-row__details--with-vault {
+ padding-left: 0;
+ }
+
.activity-event-row--portfolio .activity-event-row__transaction {
grid-column: 3;
}
@@ -667,10 +676,12 @@ const vaultDisplay = computed(() => {
44px;
}
+ /* Events carry two text lines against a short amount — give them the
+ larger share so the amount column doesn't trail off into dead space. */
.activity-event-row--portfolio {
grid-template-columns:
+ minmax(320px, 1.4fr)
minmax(280px, 1fr)
- minmax(320px, 1.2fr)
44px;
}
diff --git a/components/entities/activity/ActivityFeed.vue b/components/entities/activity/ActivityFeed.vue
index 073923258..4b1eed8cd 100644
--- a/components/entities/activity/ActivityFeed.vue
+++ b/components/entities/activity/ActivityFeed.vue
@@ -244,7 +244,10 @@ watch(feed.hasLoaded, (hasLoaded) => {
class="transition-opacity"
:class="{ 'opacity-60': feed.isRefreshing.value }"
>
-