Skip to content

Add multi-chain vault browsing - #619

Open
Seranged wants to merge 5 commits into
developmentfrom
feature/lite-242-add-multi-chain-support-with-the-connected-chain-abstracted
Open

Seranged wants to merge 5 commits into
developmentfrom
feature/lite-242-add-multi-chain-support-with-the-connected-chain-abstracted

Conversation

@Seranged

@Seranged Seranged commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds multi-chain vault browsing so users can select several supported chains and scan lend, borrow, earn, and explore surfaces together.
  • Keeps detail and transaction flows anchored to a current chain while list/catalog views can aggregate selected chains.
  • Preserves chain-specific label, registry, category, and refresh state to avoid cross-chain address collisions.

Changes

  • Adds selected-chain state, URL/localStorage sync for networks, and a multi-select chain picker in the header.
  • Loads labels and vault registries per chain, then merges catalogue data for browsing while retaining chain-scoped cache keys.
  • Adds chain filters and chain indicators across vault lists, borrow pairs, and vault detail headers.
  • Makes periodic refresh, escrow discovery, unresolved collateral lookup, and unknown-vault resolution chain-aware.

Test plan

  • npm run typecheck
  • npm run test:run
  • git diff --check origin/development...HEAD

Summary by CodeRabbit

  • New Features

    • Added multi-chain selection across the app, including chain filtering in header, explore, lend, borrow, and earn views.
    • Vault and market cards now show the relevant chain logo and chain ID.
    • Search, sorting, and reward views now respect the selected chain(s).
  • Bug Fixes

    • Improved correctness for labels, rewards, balances, and vault data when the same address exists on multiple networks.
    • Fixed chain-specific navigation and cache collisions so data stays accurate across chains.

Add selected-chain state, chain-aware label and vault registry loading, multi-chain filters, and chain indicators across vault lists and details.
@railway-app
railway-app Bot temporarily deployed to euler-lite / euler-lite-pr-619 June 24, 2026 12:08 Destroyed
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR converts the application from single-chain to multi-chain selection. It adds selectedChainIds state, threads chainId through labels, vault registry, vault loading, token lists, wallet balances, rewards/APY, and market-grouping logic, and updates UI components and pages to filter, display, and route by multiple selected chains.

Changes

Multi-chain support

Layer / File(s) Summary
Chain selection state and routing sync
composables/useEulerAddresses.ts, composables/useWagmi.ts, middleware/01.network.global.ts, app.vue
Adds selectedChainIds state with setters/toggles, syncs networks query and chainIds localStorage across route/middleware/composable, and updates the root watcher to react to selection changes.
Chain-scoped label loading
composables/useEulerLabels.ts, utils/eulerLabelsUtils.ts
Caches label data per chain, merges multi-chain datasets, and adds optional chainId parameters to label accessor helpers.
Chain-aware vault registry and categorization
composables/useVaultRegistry.ts, utils/vault/categories.ts
Keys registry entries/escrow addresses by chainId:address, and fetches/categorizes vaults per target chain.
Per-chain vault loading and verification
composables/useVaults.ts
Loads, hydrates, and refreshes vaults per chain in parallel, tracks loadedChainIds, and makes verification label building chain-aware.
Per-chain token list and wallet balances
composables/useTokenList.ts, composables/useWallets.ts
Caches token maps and wallet balances per chain, fetches balances concurrently across target chains, and updates lookup helpers to accept chainId.
Chain-aware rewards, best ROE, and market grouping
composables/useRewardsApy.ts, composables/useBestMaxROE.ts, composables/useMarketGroups.ts, utils/discoveryCalculations.ts, entities/lend-discovery.ts
Threads chainId through reward APY/campaign functions, best-ROE selection, product group construction, and discovery matrix/graph/cache calculations.
Multi-chain selector UI
components/entities/chains/ChainSelectorItem.vue, components/entities/chains/SelectChainModal.vue, components/entities/asset/AssetAvatar.vue, components/layout/TheHeader.vue
Adds a selected checkmark state to chain items, switches chain-select modal to multi-select via toggleSelectedChainId, and adds chain-scoped logo resolution and header chain-count display.
Vault item chain-aware rendering
components/entities/vault/VaultItem.vue, .../VaultBorrowItem.vue, .../VaultEarnItem.vue, .../SecuritizeVaultItem.vue, .../VaultLabelsAndAssets.vue, .../VaultMaxRoeModal.vue, .../discovery/*
Computes vault/pair chainIds, passes them into product/reward/label lookups, and renders chain logos alongside asset avatars.
Chain filtering in listing and detail pages
pages/borrow/index.vue, pages/earn/index.vue, pages/earn/[vault]/index.vue, pages/explore/index.vue, pages/lend/index.vue, pages/lend/[vault]/index.vue
Adds chain filter selects, chain-qualified cache/dedupe keys, and chain logo display in vault headers.
Test updates for chain-aware caching
tests/utils/discovery-calculations.test.ts, tests/composables/useEulerAccount.test.ts
Updates fixtures/assertions to use composite chainId:address cache keys and adjusts a test timeout.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SelectChainModal
  participant useEulerAddresses
  participant useEulerLabels
  participant useVaults
  participant useWallets

  User->>SelectChainModal: toggle chain selection
  SelectChainModal->>useEulerAddresses: toggleSelectedChainId(chainId)
  useEulerAddresses->>useEulerAddresses: update selectedChainIds
  useEulerAddresses-->>useEulerLabels: selectedChainIds changed
  useEulerLabels->>useEulerLabels: loadLabels(chainIds) per-chain fetch/merge
  useEulerLabels-->>useVaults: labels ready
  useVaults->>useVaults: loadVaultsForChain per selected chain
  useVaults-->>useWallets: loadedChainIds updated
  useWallets->>useWallets: updateBalances across target chains
Loading

Possibly related PRs

  • euler-xyz/euler-lite#112: Both PRs modify composables/useEulerLabels.ts per-chainId label loading, making the multi-chain label logic directly dependent on prior loading changes.
  • euler-xyz/euler-lite#268: Both PRs touch useVaultRegistry/utils/vault/categories.ts vault categorization/resolution paths.
  • euler-xyz/euler-lite#95: Both PRs touch useTokenList/getAssetLogoUrl, with this PR making per-chain caching/logo resolution chain-aware.

Suggested reviewers: kanvgupta

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding multi-chain vault browsing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/lite-242-add-multi-chain-support-with-the-connected-chain-abstracted

Comment @coderabbitai help to get the list of available commands.

@railway-app

railway-app Bot commented Jun 24, 2026

Copy link
Copy Markdown

🚅 Deployed to the euler-lite-pr-619 environment in euler-lite(dev,PR previews)

Service Status Web Updated (UTC)
dev-build ✅ Success (View Logs) Web Jul 31, 2026 at 9:57 am

Load and resolve token, label, curator, and market metadata by vault chain so multi-chain list views do not fall back to the active chain only.
@railway-app
railway-app Bot temporarily deployed to euler-lite / euler-lite-pr-619 June 25, 2026 10:31 Destroyed
Thread chain context through selected-chain rewards, wallet balances, aggregate sorting, and discovery caches so overlapping addresses do not read current-chain data.
@railway-app
railway-app Bot temporarily deployed to euler-lite / euler-lite-pr-619 June 25, 2026 10:49 Destroyed
Resolve borrow page conflicts between multi-chain data flow fixes and the latest filter UI changes.
@railway-app
railway-app Bot temporarily deployed to euler-lite / euler-lite-pr-619 June 25, 2026 10:55 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
utils/eulerLabelsUtils.ts (1)

113-120: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pass chainId into the earn-entry lookup.

The product lookup is chain-scoped, but the earn-entry tag check still uses the merged/default label data, so same-address earn vaults can inherit “recently added” from another chain.

Proposed fix
-    || earnEntryHasTag(getEarnEntryByVault(normalized), 'recently added')
+    || earnEntryHasTag(getEarnEntryByVault(normalized, chainId), 'recently added')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/eulerLabelsUtils.ts` around lines 113 - 120, The earn-entry lookup in
isVaultRecentlyAdded is not chain-scoped, so the recently added check can pull
tags from merged/default data for the same address on other chains. Update the
earn-entry path to pass chainId through to the lookup and tag check, using the
existing helpers around getEarnEntryByVault and earnEntryHasTag so the vault is
evaluated against the correct chain-specific labels.
composables/useEulerLabels.ts (1)

270-272: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Key wrap pairs by chain as well as address.

wrapPairs[assetAddress] = underlying can collide when the same address exists on multiple selected chains; the last probe wins and later label resolution can use the wrong underlying. Store wrap pairs per chainId:address or as a nested map and update readers accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composables/useEulerLabels.ts` around lines 270 - 272, The wrap pair lookup
in useEulerLabels is keyed only by asset address, which can overwrite entries
when the same address exists on multiple selected chains. Update the wrapPairs
data structure in the probing logic to key by chainId plus address, or switch to
a nested map, and make the label-resolution readers use the same chain-aware
lookup. Keep the change centered around the wrapPairs assignment and any code
that later consumes wrapPairs.
composables/useMarketGroups.ts (1)

529-550: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stamp on-demand fetched vaults with targetChainId.

memberVaults from sdk.eVaultService.fetchVaults() are used by chain-aware helpers (getVaultKey, label checks, metrics), but they are not assigned chainId. Direct market pages for a non-current chain can then key these as undefined:<address> and miss registry/label matches.

-      memberVaults.push(...(result.result.filter(Boolean) as EVault[]))
+      memberVaults.push(
+        ...(result.result.filter(Boolean) as EVault[])
+          .map(vault => Object.assign(vault, { chainId: targetChainId })),
+      )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composables/useMarketGroups.ts` around lines 529 - 550, The on-demand vaults
returned from fetchMarketGroupOnDemand are missing chain context, so they can be
treated as undefined by chain-aware helpers. After
sdk.eVaultService.fetchVaults() returns and before pushing into memberVaults,
stamp each fetched EVault with targetChainId so getVaultKey, label checks, and
metrics see the correct chain. Use the fetchMarketGroupOnDemand flow and
memberVaults handling as the place to apply this fix.
pages/borrow/index.vue (1)

371-421: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Chain-scoped filter values silently break legacy shared/bookmarked links.

buildAssetFilterOptions/matchesAssetFilterSelection now key collateral/debt options as ${chainId}:${address} (Lines 378, 412), and marketOptions keys markets as ${chainId}:${market.name} (Line 448 area). Previously these were presumably bare addresses/names. Any existing bookmarked or shared URL containing ?collateral=, ?debt=, or ?market= with the old (chain-less) value will no longer match any asset (selectedValue === assetValue fails, and the value isn't a category-filter token either), silently producing an empty filtered list instead of the previously selected filter — with no error surfaced to the user.

Consider accepting the legacy bare-address/name format as a fallback match (e.g., if selectedValue doesn't contain a chain prefix, compare against asset.address/market.name alone) to avoid breaking existing links.

🔧 Suggested fallback for legacy (chain-less) filter values
 const matchesAssetFilterSelection = (
   asset: BorrowFilterAsset,
   selected: readonly string[],
 ): boolean => {
   if (!selected.length) return true

   const assetValue = `${asset.chainId}:${asset.address}`
   return selected.some(selectedValue =>
-    selectedValue === assetValue || tokenAddressMatchesCategoryFilter(asset.address, selectedValue, address => getTokenCategoryTags(address, asset.chainId)),
+    selectedValue === assetValue
+    || selectedValue.toLowerCase() === asset.address.toLowerCase() // legacy bare-address links
+    || tokenAddressMatchesCategoryFilter(asset.address, selectedValue, address => getTokenCategoryTags(address, asset.chainId)),
   )
 }

Also applies to: 437-450

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/borrow/index.vue` around lines 371 - 421, Chain-scoped filter values in
buildAssetFilterOptions, matchesAssetFilterSelection, and the market filter
logic now only match ${chainId}:... values, which breaks legacy
bookmarked/shared links that still use bare addresses or market names. Update
the selection matching in matchesAssetFilterSelection and the market option
lookup to accept both the new chain-prefixed form and the old chain-less form as
a fallback, so existing ?collateral=, ?debt=, and ?market= URLs continue to
resolve correctly.
pages/explore/index.vue (2)

322-344: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Treat selectedChains as an active clearable filter.

A chain-only filter currently does not set hasActiveFilters, and clearExploreFilters() leaves it selected.

Proposed fix
 const hasActiveFilters = computed(() =>
   searchQuery.value.trim().length > 0
+  || selectedChains.value.length > 0
   || selectedMarkets.value.length > 0
   || selectedAssets.value.length > 0
   || selectedRiskManagers.value.length > 0
@@
 const clearExploreFilters = () => {
   clearSearch()
+  selectedChains.value = []
   selectedMarkets.value = []
   selectedAssets.value = []
   selectedRiskManagers.value = []

Also applies to: 379-387

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/explore/index.vue` around lines 322 - 344, Treat selectedChains as an
active filter in the explore state: update the computed hasActiveFilters in
pages/explore/index.vue to include selectedChains.value.length, and make
clearExploreFilters also reset selectedChains so chain-only filtering is
detected and cleared like the other filters. Apply the same change anywhere else
the filter summary/clear logic is duplicated in the explore view.

31-39: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass chainId through applyVaultOverrides.

The search index uses chain-scoped product data, but override resolution is still address-only; this can index/display another chain’s override for overlapping vault addresses.

Proposed fix
-    const product = applyVaultOverrides(getProductByVault(addr, vault.chainId), addr)
+    const product = applyVaultOverrides(getProductByVault(addr, vault.chainId), addr, vault.chainId)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/explore/index.vue` around lines 31 - 39, The search indexing in the
useVaultSearch callback is still resolving overrides without chain context,
which can mix product overrides across chains for the same vault address. Update
the call site in the explore page to pass the vault’s chainId through
applyVaultOverrides, alongside getProductByVault(addr, vault.chainId), so
override resolution stays chain-scoped and the indexed/displayed product data
matches the correct network.
pages/earn/index.vue (1)

249-269: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include the chain filter in active and clear filter state.

With only selectedChains set, the page can show an unfiltered empty-state message and the clear action will not reset the chain filter.

Proposed fix
 const hasActiveFilters = computed(() =>
   searchQuery.value.trim().length > 0
+  || selectedChains.value.length > 0
   || selectedCollateral.value.length > 0
   || selectedCurators.value.length > 0
   || customFilters.value.length > 0,
 )
@@
 const clearEarnFilters = () => {
   clearSearch()
+  selectedChains.value = []
   selectedCollateral.value = []
   selectedCurators.value = []
   clearCustomFilters()
 }

Also applies to: 301-309

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/earn/index.vue` around lines 249 - 269, The active/clear filter state
in the earn page ignores the chain selector, so add selectedChains to the filter
checks and reset flow. Update hasActiveFilters and any related empty-state logic
in the earn page to treat selectedChains as an active filter, and make
clearEarnFilters also clear selectedChains alongside the other filter arrays. If
there is a second mirrored filter state block around the earn page’s later
section, keep its chain handling consistent with the same symbols.
pages/lend/index.vue (1)

303-325: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include selectedChains in clearable filter state.

A chain-only filter is not counted as active and is not cleared by clearLendFilters(), leaving users without a clear action when that filter empties the list.

Proposed fix
 const hasActiveFilters = computed(() =>
   searchQuery.value.trim().length > 0
+  || selectedChains.value.length > 0
   || selectedCollateral.value.length > 0
   || selectedMarkets.value.length > 0
   || selectedRiskManagers.value.length > 0
@@
 const clearLendFilters = () => {
   clearSearch()
+  selectedChains.value = []
   selectedCollateral.value = []
   selectedMarkets.value = []
   selectedRiskManagers.value = []

Also applies to: 357-365

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/lend/index.vue` around lines 303 - 325, The filter state logic in the
lend page is missing selectedChains, so chain-only filtering can hide markets
without being reflected in the active-filter state or reset action. Update the
computed active-filter check used by hasActiveFilters to include selectedChains,
and make clearLendFilters clear selectedChains alongside the other filter
arrays; use the existing selectedChains, hasActiveFilters, and clearLendFilters
symbols to keep the empty-state and reset behavior consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/entities/chains/SelectChainModal.vue`:
- Around line 21-29: The SelectChainModal click handler currently allows the
last selected chain to be deselected, which can leave useEulerAddresses with an
empty selectedChainIds array and cause chainId to fall back to 0. Update the
onClick logic in SelectChainModal.vue to guard against deselecting the final
remaining chain by no-oping or disabling the action when
selectedChainIds.value.length === 1 and isSelected(chainId) is true. Keep the
behavior aligned with toggleSelectedChainId from useEulerAddresses so downstream
chainId-dependent lookups do not receive an invalid value.

In `@components/layout/TheHeader.vue`:
- Around line 18-24: The header is using the lowest selected chain instead of
the actual active chain, so the “current chain” display becomes incorrect.
Update TheHeader.vue to derive the chain logo/label from chainId directly rather
than selectedChainIds[0], and keep selectedChainIds only for the multi-chain
count text in selectedChainLabel. Ensure primarySelectedChainId (or its
replacement) tracks chainId so the avatar and single-chain label always reflect
the active chain.

In `@composables/useEulerLabels.ts`:
- Around line 76-77: The chain-specific lookup in getEulerLabelsDataForChain
currently falls back to labelsData, which can return merged cross-chain metadata
for a requested chainId on cache miss. Update this function so explicit chain
lookups only return the entry from labelsByChainId for that chain, and otherwise
return an empty EulerLabelsData or another clearly scoped per-chain default
instead of the merged dataset.
- Around line 188-210: The loadLabels() flow in useEulerLabels.ts is swallowing
failure/stale state: the inner Promise<boolean> currently returns true in the
catch path, and the outer async wrapper awaits it but never returns the boolean
to callers like app.vue. Update loadLabels() so the catch path yields false for
failed loads, and make the outer function return the awaited promise result
while preserving the pendingLabelsLoad cleanup and the
generation/isLoading/isReady logic.

In `@composables/useVaultRegistry.ts`:
- Around line 77-80: The chain-specific lookup in get(address, chainId) still
falls back to findUniqueEntryByAddress(address), which can return a value from
another chain when the requested chain key is missing; change this so an
explicit chainId only checks the exact registry key and does not cross-fallback.
Apply the same exact-chain-only behavior to isKnownEscrowAddress(address,
chainId) so both helpers honor chain isolation consistently, using the existing
normalizeRegistryKey, registry.value.get, and findUniqueEntryByAddress symbols
to locate the logic.

In `@composables/useVaults.ts`:
- Around line 817-842: The global readiness state is being updated too early
because loadVaultsForChain() marks isReady and mutates loadedChainIds
independently, which can make multi-chain pages look complete before all
requested chains finish loading. Move the global isReady.value = true logic out
of loadVaultsForChain() and keep it in loadVaults after the Promise.all over
targetChainIds completes, or otherwise gate it on all requested chains settling.
Keep the chain-specific loadedChainId/loadedChainIds updates in
loadVaultsForChain(), but only publish global readiness from loadVaults once
every target chain is done.
- Around line 179-182: `isCurrentVaultLoad()` is using different chain fallback
logic than `loadVaults()`, so it can reject a load when `selectedChainIds.value`
is empty. Update `isCurrentVaultLoad()` in `useVaults.ts` to mirror the same
fallback used by `loadVaults()`: derive the active chain IDs from
`selectedChainIds.value` when present, otherwise fall back to `chainId.value`,
and then compare `targetChainId` against that resolved list. Keep
`loadGeneration` as the generation check and ensure the logic stays consistent
with `useEulerAddresses()`.

In `@pages/earn/index.vue`:
- Around line 38-40: The override lookup in useVaultSearch for EulerEarn is only
using the vault address, which can select the wrong metadata when the same
address exists on multiple chains. Update the product resolution flow so
applyVaultOverrides receives the vault chainId as well as the address, and
thread that chain context through the getProductByVault result handling in
pages/earn/index.vue. Keep the change localized to the search callback so
cross-chain vaults resolve the correct market metadata.

In `@pages/lend/index.vue`:
- Around line 130-134: The borrowable vault filter in computed borrowableVaults
is matching borrowList entries by address only, which can incorrectly mark
same-address vaults on different chains as borrowable. Update the some() check
to match both borrow.address and borrow.chainId against the current
vault.address and vault.chainId, keeping the existing showAllLabelEntries and
isOpDisabled conditions unchanged.
- Around line 43-44: The vault search lookup is resolving overrides using only
the vault address, which can surface the wrong override when the same address
exists on multiple chains. Update the lookup inside useVaultSearch so the
override resolution path in applyVaultOverrides also receives vault.chainId,
matching the chain-aware getProductByVault call. Keep the fix localized to the
vault product resolution logic in pages/lend/index.vue and ensure both lookup
steps use the same chain context.

---

Outside diff comments:
In `@composables/useEulerLabels.ts`:
- Around line 270-272: The wrap pair lookup in useEulerLabels is keyed only by
asset address, which can overwrite entries when the same address exists on
multiple selected chains. Update the wrapPairs data structure in the probing
logic to key by chainId plus address, or switch to a nested map, and make the
label-resolution readers use the same chain-aware lookup. Keep the change
centered around the wrapPairs assignment and any code that later consumes
wrapPairs.

In `@composables/useMarketGroups.ts`:
- Around line 529-550: The on-demand vaults returned from
fetchMarketGroupOnDemand are missing chain context, so they can be treated as
undefined by chain-aware helpers. After sdk.eVaultService.fetchVaults() returns
and before pushing into memberVaults, stamp each fetched EVault with
targetChainId so getVaultKey, label checks, and metrics see the correct chain.
Use the fetchMarketGroupOnDemand flow and memberVaults handling as the place to
apply this fix.

In `@pages/borrow/index.vue`:
- Around line 371-421: Chain-scoped filter values in buildAssetFilterOptions,
matchesAssetFilterSelection, and the market filter logic now only match
${chainId}:... values, which breaks legacy bookmarked/shared links that still
use bare addresses or market names. Update the selection matching in
matchesAssetFilterSelection and the market option lookup to accept both the new
chain-prefixed form and the old chain-less form as a fallback, so existing
?collateral=, ?debt=, and ?market= URLs continue to resolve correctly.

In `@pages/earn/index.vue`:
- Around line 249-269: The active/clear filter state in the earn page ignores
the chain selector, so add selectedChains to the filter checks and reset flow.
Update hasActiveFilters and any related empty-state logic in the earn page to
treat selectedChains as an active filter, and make clearEarnFilters also clear
selectedChains alongside the other filter arrays. If there is a second mirrored
filter state block around the earn page’s later section, keep its chain handling
consistent with the same symbols.

In `@pages/explore/index.vue`:
- Around line 322-344: Treat selectedChains as an active filter in the explore
state: update the computed hasActiveFilters in pages/explore/index.vue to
include selectedChains.value.length, and make clearExploreFilters also reset
selectedChains so chain-only filtering is detected and cleared like the other
filters. Apply the same change anywhere else the filter summary/clear logic is
duplicated in the explore view.
- Around line 31-39: The search indexing in the useVaultSearch callback is still
resolving overrides without chain context, which can mix product overrides
across chains for the same vault address. Update the call site in the explore
page to pass the vault’s chainId through applyVaultOverrides, alongside
getProductByVault(addr, vault.chainId), so override resolution stays
chain-scoped and the indexed/displayed product data matches the correct network.

In `@pages/lend/index.vue`:
- Around line 303-325: The filter state logic in the lend page is missing
selectedChains, so chain-only filtering can hide markets without being reflected
in the active-filter state or reset action. Update the computed active-filter
check used by hasActiveFilters to include selectedChains, and make
clearLendFilters clear selectedChains alongside the other filter arrays; use the
existing selectedChains, hasActiveFilters, and clearLendFilters symbols to keep
the empty-state and reset behavior consistent.

In `@utils/eulerLabelsUtils.ts`:
- Around line 113-120: The earn-entry lookup in isVaultRecentlyAdded is not
chain-scoped, so the recently added check can pull tags from merged/default data
for the same address on other chains. Update the earn-entry path to pass chainId
through to the lookup and tag check, using the existing helpers around
getEarnEntryByVault and earnEntryHasTag so the vault is evaluated against the
correct chain-specific labels.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: euler-xyz/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 376005c3-5884-4cf1-bb2f-b9cb4b966ff1

📥 Commits

Reviewing files that changed from the base of the PR and between 31d1edd and 46a0160.

📒 Files selected for processing (39)
  • app.vue
  • components/entities/asset/AssetAvatar.vue
  • components/entities/chains/ChainSelectorItem.vue
  • components/entities/chains/SelectChainModal.vue
  • components/entities/vault/SecuritizeVaultItem.vue
  • components/entities/vault/VaultBorrowItem.vue
  • components/entities/vault/VaultEarnItem.vue
  • components/entities/vault/VaultItem.vue
  • components/entities/vault/VaultLabelsAndAssets.vue
  • components/entities/vault/VaultMaxRoeModal.vue
  • components/entities/vault/discovery/DiscoveryMarketAccordion.vue
  • components/entities/vault/discovery/DiscoveryMarketAttributeMatrix.vue
  • components/entities/vault/discovery/DiscoveryMarketCard.vue
  • components/entities/vault/discovery/DiscoveryMarketGraph.vue
  • components/entities/vault/discovery/DiscoveryMarketMatrix.vue
  • components/layout/TheHeader.vue
  • composables/useBestMaxROE.ts
  • composables/useEulerAddresses.ts
  • composables/useEulerLabels.ts
  • composables/useMarketGroups.ts
  • composables/useRewardsApy.ts
  • composables/useTokenList.ts
  • composables/useVaultRegistry.ts
  • composables/useVaults.ts
  • composables/useWagmi.ts
  • composables/useWallets.ts
  • entities/lend-discovery.ts
  • middleware/01.network.global.ts
  • pages/borrow/index.vue
  • pages/earn/[vault]/index.vue
  • pages/earn/index.vue
  • pages/explore/index.vue
  • pages/lend/[vault]/index.vue
  • pages/lend/index.vue
  • tests/composables/useEulerAccount.test.ts
  • tests/utils/discovery-calculations.test.ts
  • utils/discoveryCalculations.ts
  • utils/eulerLabelsUtils.ts
  • utils/vault/categories.ts

Comment on lines +21 to 29
const { selectedChainIds, toggleSelectedChainId } = useEulerAddresses()

const handleClose = () => {
emits('close')
}
const isSelected = (chainId: number) => selectedChainIds.value.includes(chainId)
const onClick = (chainId: number) => {
emits('close')
setTimeout(() => {
changeChain(chainId)
}, 400)
toggleSelectedChainId(chainId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Allow deselecting all chains — no minimum-selection guard.

onClick calls toggleSelectedChainId(chainId) unconditionally. Per the upstream contract in useEulerAddresses.ts, if the user toggles off every chain, selectedChainIds becomes [] and chainId.value falls back to 0 (selectedChainIds.value[0] || 0). A chainId of 0 is not a valid chain and will likely break downstream label/vault/registry lookups that key off chainId.

Consider preventing deselection of the last remaining chain (e.g., disable the click or no-op when selectedChainIds.value.length === 1 && isSelected(chainId)).

🛡️ Proposed guard
 const onClick = (chainId: number) => {
+  if (selectedChainIds.value.length === 1 && isSelected(chainId)) return
   toggleSelectedChainId(chainId)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { selectedChainIds, toggleSelectedChainId } = useEulerAddresses()
const handleClose = () => {
emits('close')
}
const isSelected = (chainId: number) => selectedChainIds.value.includes(chainId)
const onClick = (chainId: number) => {
emits('close')
setTimeout(() => {
changeChain(chainId)
}, 400)
toggleSelectedChainId(chainId)
}
const { selectedChainIds, toggleSelectedChainId } = useEulerAddresses()
const handleClose = () => {
emits('close')
}
const isSelected = (chainId: number) => selectedChainIds.value.includes(chainId)
const onClick = (chainId: number) => {
if (selectedChainIds.value.length === 1 && isSelected(chainId)) return
toggleSelectedChainId(chainId)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/entities/chains/SelectChainModal.vue` around lines 21 - 29, The
SelectChainModal click handler currently allows the last selected chain to be
deselected, which can leave useEulerAddresses with an empty selectedChainIds
array and cause chainId to fall back to 0. Update the onClick logic in
SelectChainModal.vue to guard against deselecting the final remaining chain by
no-oping or disabling the action when selectedChainIds.value.length === 1 and
isSelected(chainId) is true. Keep the behavior aligned with
toggleSelectedChainId from useEulerAddresses so downstream chainId-dependent
lookups do not receive an invalid value.

Comment on lines +18 to +24
const { chainId, selectedChainIds, allowedChainIds } = useEulerAddresses()
const primarySelectedChainId = computed(() => selectedChainIds.value[0] ?? chainId.value)
const chainLogoSrc = computed(() => getChainLogoUrl(primarySelectedChainId.value))
const selectedChainLabel = computed(() => {
const count = selectedChainIds.value.length || 1
return count === 1 ? String(primarySelectedChainId.value) : `${count} chains`
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Header shows wrong "current chain" when active chain isn't the lowest selected id.

selectedChainIds is sorted ascending and only resyncs to chainId when the active chain is removed from selection — otherwise chainId can be any element, not necessarily selectedChainIds[0]. Since selectedChainIds is never empty by design, selectedChainIds.value[0] ?? chainId.value always resolves to the lowest-id selected chain, never the actual active chain. E.g., user is on chain 137, adds chain 1 → header now shows chain 1's logo/label as "current chain" even though the app's active/transaction chain is still 137. This replaces a prior direct binding to chainId for the avatar label.

🐛 Proposed fix
-const primarySelectedChainId = computed(() => selectedChainIds.value[0] ?? chainId.value)
+const primarySelectedChainId = computed(() => chainId.value || selectedChainIds.value[0])

Also applies to: 247-252, 259-264

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/layout/TheHeader.vue` around lines 18 - 24, The header is using
the lowest selected chain instead of the actual active chain, so the “current
chain” display becomes incorrect. Update TheHeader.vue to derive the chain
logo/label from chainId directly rather than selectedChainIds[0], and keep
selectedChainIds only for the multi-chain count text in selectedChainLabel.
Ensure primarySelectedChainId (or its replacement) tracks chainId so the avatar
and single-chain label always reflect the active chain.

Comment on lines +76 to +77
export const getEulerLabelsDataForChain = (chainId: number): EulerLabelsData =>
labelsByChainId.value.get(chainId) ?? labelsData.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Avoid falling back to merged labels for explicit chain lookups.

When a caller asks for a specific chainId, returning merged labels on cache miss can attach another chain’s product/entity metadata to the same address. Return an empty dataset or require the per-chain cache to be populated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composables/useEulerLabels.ts` around lines 76 - 77, The chain-specific
lookup in getEulerLabelsDataForChain currently falls back to labelsData, which
can return merged cross-chain metadata for a requested chainId on cache miss.
Update this function so explicit chain lookups only return the entry from
labelsByChainId for that chain, and otherwise return an empty EulerLabelsData or
another clearly scoped per-chain default instead of the merged dataset.

Comment on lines 188 to 210
catch (e) {
logWarn('labels/load', e)
return true
}
finally {
isLoading.value = false
isReady.value = true
if (generation === labelsLoadGeneration) {
isLoading.value = false
isReady.value = true
}
}
})()

pendingLabelsLoad = promise
pendingLabelsLoadKey = targetKey
try {
await promise
}
finally {
if (pendingLabelsLoad === promise) pendingLabelsLoad = undefined
if (pendingLabelsLoad === promise) {
pendingLabelsLoad = undefined
pendingLabelsLoadKey = ''
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate failed or stale label-load results.

loadLabels() builds a Promise<boolean>, but the outer function only awaits it and returns undefined, so callers like app.vue cannot honor loaded === false. Also, the catch path currently reports success after a failed fetch.

Proposed fix
     catch (e) {
       logWarn('labels/load', e)
-      return true
+      return false
     }
@@
   pendingLabelsLoad = promise
   pendingLabelsLoadKey = targetKey
   try {
-    await promise
+    return await promise
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catch (e) {
logWarn('labels/load', e)
return true
}
finally {
isLoading.value = false
isReady.value = true
if (generation === labelsLoadGeneration) {
isLoading.value = false
isReady.value = true
}
}
})()
pendingLabelsLoad = promise
pendingLabelsLoadKey = targetKey
try {
await promise
}
finally {
if (pendingLabelsLoad === promise) pendingLabelsLoad = undefined
if (pendingLabelsLoad === promise) {
pendingLabelsLoad = undefined
pendingLabelsLoadKey = ''
}
}
catch (e) {
logWarn('labels/load', e)
return false
}
finally {
if (generation === labelsLoadGeneration) {
isLoading.value = false
isReady.value = true
}
}
})()
pendingLabelsLoad = promise
pendingLabelsLoadKey = targetKey
try {
return await promise
}
finally {
if (pendingLabelsLoad === promise) {
pendingLabelsLoad = undefined
pendingLabelsLoadKey = ''
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composables/useEulerLabels.ts` around lines 188 - 210, The loadLabels() flow
in useEulerLabels.ts is swallowing failure/stale state: the inner
Promise<boolean> currently returns true in the catch path, and the outer async
wrapper awaits it but never returns the boolean to callers like app.vue. Update
loadLabels() so the catch path yields false for failed loads, and make the outer
function return the awaited promise result while preserving the
pendingLabelsLoad cleanup and the generation/isLoading/isReady logic.

Comment on lines +77 to 80
const get = (address: string, chainId = getDefaultChainId()): VaultEntry | undefined => {
const keyed = chainId ? registry.value.get(normalizeRegistryKey(address, chainId)) : undefined
return keyed ?? findUniqueEntryByAddress(address)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Avoid cross-chain fallback when a chain is specified.

get(address, chainId) and isKnownEscrowAddress(address, chainId) can return data from another chain if the target-chain key is missing but the address exists uniquely elsewhere. That reintroduces cross-chain address collisions for callers that explicitly pass chainId.

Proposed direction
-const get = (address: string, chainId = getDefaultChainId()): VaultEntry | undefined => {
-  const keyed = chainId ? registry.value.get(normalizeRegistryKey(address, chainId)) : undefined
-  return keyed ?? findUniqueEntryByAddress(address)
+const get = (address: string, chainId?: number): VaultEntry | undefined => {
+  const targetChainId = chainId ?? getDefaultChainId()
+  if (targetChainId) return registry.value.get(normalizeRegistryKey(address, targetChainId))
+  return findUniqueEntryByAddress(address)
 }

Apply the same exact-chain behavior to isKnownEscrowAddress.

As per path instructions, chain-specific cache/registry values must not collide across Euler V2 chains.

Also applies to: 158-162

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composables/useVaultRegistry.ts` around lines 77 - 80, The chain-specific
lookup in get(address, chainId) still falls back to
findUniqueEntryByAddress(address), which can return a value from another chain
when the requested chain key is missing; change this so an explicit chainId only
checks the exact registry key and does not cross-fallback. Apply the same
exact-chain-only behavior to isKnownEscrowAddress(address, chainId) so both
helpers honor chain isolation consistently, using the existing
normalizeRegistryKey, registry.value.get, and findUniqueEntryByAddress symbols
to locate the logic.

Source: Path instructions

Comment thread composables/useVaults.ts
Comment on lines 179 to +182
const isCurrentVaultLoad = (generation: number, targetChainId: number): boolean => {
const { chainId } = useEulerAddresses()
return loadGeneration.value === generation && chainId.value === targetChainId
const { chainId, selectedChainIds } = useEulerAddresses()
const activeChainIds = selectedChainIds?.value ?? [chainId.value].filter(Boolean)
return loadGeneration.value === generation && activeChainIds.includes(targetChainId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the same fallback logic as loadVaults().

When selectedChainIds.value is [], loadVaults() loads chainId.value, but isCurrentVaultLoad() checks against [] and rejects that load.

-  const activeChainIds = selectedChainIds?.value ?? [chainId.value].filter(Boolean)
+  const selectedIds = selectedChainIds?.value ?? []
+  const activeChainIds = selectedIds.length ? selectedIds : [chainId.value].filter(Boolean)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isCurrentVaultLoad = (generation: number, targetChainId: number): boolean => {
const { chainId } = useEulerAddresses()
return loadGeneration.value === generation && chainId.value === targetChainId
const { chainId, selectedChainIds } = useEulerAddresses()
const activeChainIds = selectedChainIds?.value ?? [chainId.value].filter(Boolean)
return loadGeneration.value === generation && activeChainIds.includes(targetChainId)
const isCurrentVaultLoad = (generation: number, targetChainId: number): boolean => {
const { chainId, selectedChainIds } = useEulerAddresses()
const selectedIds = selectedChainIds?.value ?? []
const activeChainIds = selectedIds.length ? selectedIds : [chainId.value].filter(Boolean)
return loadGeneration.value === generation && activeChainIds.includes(targetChainId)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composables/useVaults.ts` around lines 179 - 182, `isCurrentVaultLoad()` is
using different chain fallback logic than `loadVaults()`, so it can reject a
load when `selectedChainIds.value` is empty. Update `isCurrentVaultLoad()` in
`useVaults.ts` to mirror the same fallback used by `loadVaults()`: derive the
active chain IDs from `selectedChainIds.value` when present, otherwise fall back
to `chainId.value`, and then compare `targetChainId` against that resolved list.
Keep `loadGeneration` as the generation check and ensure the logic stays
consistent with `useEulerAddresses()`.

Comment thread composables/useVaults.ts
Comment on lines +817 to +842
if (loadGeneration.value === generation) {
isReady.value = true
loadedChainId.value = startChainId
if (!loadedChainIds.value.includes(startChainId)) {
loadedChainIds.value = [...loadedChainIds.value, startChainId].sort((a, b) => a - b)
}
const { chainId } = useEulerAddresses()
if (chainId.value === startChainId) {
loadedChainId.value = startChainId
}
}
}
}

const loadVaults = async () => {
const { selectedChainIds, chainId } = useEulerAddresses()
resetVaultsState()
const generation = loadGeneration.value
const selectedIds = selectedChainIds?.value ?? []
const targetChainIds = selectedIds.length ? selectedIds : [chainId.value].filter(Boolean)

await Promise.all(targetChainIds.map(targetChainId => loadVaultsForChain(targetChainId, generation)))

if (loadGeneration.value === generation) {
isReady.value = true
if (!loadedChainId.value && chainId.value && loadedChainIds.value.includes(chainId.value)) {
loadedChainId.value = chainId.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Don’t mark global vault state ready after the first chain finishes.

loadVaultsForChain() sets isReady and appends loadedChainIds independently, so multi-chain pages can render as ready while other selected chains are still loading. Move global readiness to the outer Promise.all completion, or track readiness per target chain and only publish global readiness once every requested chain has settled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@composables/useVaults.ts` around lines 817 - 842, The global readiness state
is being updated too early because loadVaultsForChain() marks isReady and
mutates loadedChainIds independently, which can make multi-chain pages look
complete before all requested chains finish loading. Move the global
isReady.value = true logic out of loadVaultsForChain() and keep it in loadVaults
after the Promise.all over targetChainIds completes, or otherwise gate it on all
requested chains settling. Keep the chain-specific loadedChainId/loadedChainIds
updates in loadVaultsForChain(), but only publish global readiness from
loadVaults once every target chain is done.

Comment thread pages/earn/index.vue
Comment on lines 38 to 40
const { searchQuery, matchesSearch, clearSearch } = useVaultSearch<EulerEarn>((vault) => {
const product = applyVaultOverrides(getProductByVault(vault.address), vault.address)
const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address)
return [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass the vault chain into override resolution.

getProductByVault is chain-scoped, but applyVaultOverrides still receives only the address, so cross-chain address collisions can apply the wrong market metadata.

Proposed fix
-  const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address)
+  const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address, vault.chainId)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { searchQuery, matchesSearch, clearSearch } = useVaultSearch<EulerEarn>((vault) => {
const product = applyVaultOverrides(getProductByVault(vault.address), vault.address)
const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address)
return [
const { searchQuery, matchesSearch, clearSearch } = useVaultSearch<EulerEarn>((vault) => {
const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address, vault.chainId)
return [
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/earn/index.vue` around lines 38 - 40, The override lookup in
useVaultSearch for EulerEarn is only using the vault address, which can select
the wrong metadata when the same address exists on multiple chains. Update the
product resolution flow so applyVaultOverrides receives the vault chainId as
well as the address, and thread that chain context through the getProductByVault
result handling in pages/earn/index.vue. Keep the change localized to the search
callback so cross-chain vaults resolve the correct market metadata.

Comment thread pages/lend/index.vue
Comment on lines 43 to +44
const { searchQuery, matchesSearch, clearSearch } = useVaultSearch<EVault>((vault) => {
const product = applyVaultOverrides(getProductByVault(vault.address), vault.address)
const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass chainId through vault override lookup.

The base product lookup is chain-aware, but override resolution remains address-only, which can show/search the wrong override for duplicate vault addresses across chains.

Proposed fix
-  const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address)
+  const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address, vault.chainId)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { searchQuery, matchesSearch, clearSearch } = useVaultSearch<EVault>((vault) => {
const product = applyVaultOverrides(getProductByVault(vault.address), vault.address)
const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address)
const { searchQuery, matchesSearch, clearSearch } = useVaultSearch<EVault>((vault) => {
const product = applyVaultOverrides(getProductByVault(vault.address, vault.chainId), vault.address, vault.chainId)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/lend/index.vue` around lines 43 - 44, The vault search lookup is
resolving overrides using only the vault address, which can surface the wrong
override when the same address exists on multiple chains. Update the lookup
inside useVaultSearch so the override resolution path in applyVaultOverrides
also receives vault.chainId, matching the chain-aware getProductByVault call.
Keep the fix localized to the vault product resolution logic in
pages/lend/index.vue and ensure both lookup steps use the same chain context.

Comment thread pages/lend/index.vue
Comment on lines 130 to 134
const borrowableVaults = computed(() => {
return list.value.filter(vault =>
(showAllLabelEntries.value || !isVaultNotExplorableLend(vault.address))
(showAllLabelEntries.value || !isVaultNotExplorableLend(vault.address, vault.chainId))
&& borrowList.value.some(pair => pair.borrow.address === vault.address)
&& !isOpDisabled(vault, OP_DEPOSIT),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Match borrowable vault pairs by chain and address.

borrowList is now multi-chain, so address-only matching can mark a vault borrowable because another chain has the same vault address.

Proposed fix
-    && borrowList.value.some(pair => pair.borrow.address === vault.address)
+    && borrowList.value.some(pair =>
+      pair.borrow.chainId === vault.chainId
+      && pair.borrow.address.toLowerCase() === vault.address.toLowerCase(),
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const borrowableVaults = computed(() => {
return list.value.filter(vault =>
(showAllLabelEntries.value || !isVaultNotExplorableLend(vault.address))
(showAllLabelEntries.value || !isVaultNotExplorableLend(vault.address, vault.chainId))
&& borrowList.value.some(pair => pair.borrow.address === vault.address)
&& !isOpDisabled(vault, OP_DEPOSIT),
const borrowableVaults = computed(() => {
return list.value.filter(vault =>
(showAllLabelEntries.value || !isVaultNotExplorableLend(vault.address, vault.chainId))
&& borrowList.value.some(pair =>
pair.borrow.chainId === vault.chainId
&& pair.borrow.address.toLowerCase() === vault.address.toLowerCase(),
)
&& !isOpDisabled(vault, OP_DEPOSIT),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/lend/index.vue` around lines 130 - 134, The borrowable vault filter in
computed borrowableVaults is matching borrowList entries by address only, which
can incorrectly mark same-address vaults on different chains as borrowable.
Update the some() check to match both borrow.address and borrow.chainId against
the current vault.address and vault.chainId, keeping the existing
showAllLabelEntries and isOpDisabled conditions unchanged.

@LeonardEulerXYZ LeonardEulerXYZ left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leonard review — PR #619

Verdict: Request changes.

I reviewed the current head 46a0160 as a multi-chain browsing/risk-assessment pass. The main architecture is moving in the right direction — chain-qualified keys are being threaded through labels, rewards, registry, list filters, and discovery metrics — but I found several correctness/UX issues that should be fixed before merge.

Scope covered:

  • data/composables: labels, vault registry, vault loading, token/reward lookups, market groups
  • user-facing routes: Explore, Lend, Earn, Borrow, header chain selector, vault cards/detail anchoring
  • bot feedback: CodeRabbit currently only has the processing/walkthrough comment on this head; no actionable inline comments were present to verify
  • scalability / maintainability hygiene: checked sibling list pages and shared chain-scoped registry/data paths, not only the first touched component

Validation performed:

  • npm test -- --run tests/composables/useEulerAccount.test.ts tests/utils/discovery-calculations.test.ts — pass, 19 tests
  • npm run typecheck — pass
  • npm run build — pass, with existing chunk/module-preload style warnings
  • git diff --check origin/development...HEAD on the reviewed diff — pass
  • Browser visual smoke on the PR Railway preview, headed Chromium under Xvfb:
    • desktop /lend?network=1&networks=1 — loaded real Lend content; header renders the selected chain as 1
    • mobile /lend?network=1&networks=1 — loaded real Lend content; chain chip is icon-only on mobile but text extraction still exposes 1
    • desktop /explore?network=1&networks=1,8453 — loaded real Explore content; header renders 2 chains

Screenshot evidence:

Findings are inline. The two highest-risk ones are the explicit-chain registry fallback and the global readiness flag becoming true before all selected chains finish loading. Both can make a multi-chain page look settled while it is using wrong-chain or partial-chain data — a small symbol error with a rather large denominator.

Smoke coverage: browser visual smoke + mobile smoke, no wallet/signing smoke.

return registry.value.get(normalizeAddress(address))
const get = (address: string, chainId = getDefaultChainId()): VaultEntry | undefined => {
const keyed = chainId ? registry.value.get(normalizeRegistryKey(address, chainId)) : undefined
return keyed ?? findUniqueEntryByAddress(address)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback is unsafe when the caller supplied an explicit chainId. If chain 2 asks for get(address, 2) before that chain's entry is loaded, and the same address exists only once in the registry on chain 1, findUniqueEntryByAddress() returns the chain-1 vault. The downstream typed helpers (has, getType, isVerifiedVault, escrow/category checks) then treat a wrong-chain entry as valid. For multi-chain browsing this is a data-integrity bug: address-only fallback should only apply to genuinely address-only legacy calls, not after an explicit chain miss. Please add a focused same-address/different-chain registry test as well.

Comment thread composables/useVaults.ts
}
finally {
if (loadGeneration.value === generation && chainId.value === startChainId) {
if (loadGeneration.value === generation) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the global isReady flag true as soon as the first selected chain finishes. loadVaults() starts all selected chains concurrently, but every loadVaultsForChain() publishes readiness in its own finally; consumers such as market groups treat that as the selected multi-chain dataset being ready. A fast chain can therefore render a partial catalogue while slower selected chains are still loading. Please keep readiness false until all target chain loads complete, or derive it from selectedChainIds.every(id => loadedChainIds.includes(id)).

const sdk = await getEulerSdk()
const result = await sdk.eVaultService.fetchVaults(
chainId.value,
targetChainId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The on-demand direct-market path fetches from targetChainId, but the returned SDK vaults are pushed into memberVaults without tagging them with that targetChainId. The main vault-loading path now uses chain-tagged vaults, and the rest of this file keys labels/graph data off vault.chainId; direct routes for a non-current or chainId:productKey market can end up with undefined chain IDs and wrong/fallback label lookups. Please assign the target chain to each fetched vault before computing metrics/graph data, and cover the direct-route/on-demand case in a focused test.

Comment thread pages/explore/index.vue
return getUniqueEntitiesByVaults(group.vaults).some(e => selectedRiskManagers.value.includes(e.name))
}

const matchesChainFilter = (group: MarketGroup): boolean => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Chain selector is a real filter, but the active-filter/clear-filter path does not include it. On Explore, Lend, Earn, and Borrow, selectedChains is applied to the list but omitted from hasActiveFilters/hasClearableFilters and from the corresponding clear function. If a chain filter hides all results, users can get a misleading No markets/vaults yet state with no useful clear action; if another filter exposes Clear filters, the chain filter remains applied. Please wire selectedChains.length and reset it consistently across all four list pages.

const chainLogoSrc = computed(() => getChainLogoUrl(primarySelectedChainId.value))
const selectedChainLabel = computed(() => {
const count = selectedChainIds.value.length || 1
return count === 1 ? String(primarySelectedChainId.value) : `${count} chains`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a single selected chain this renders the raw numeric chain id (1, 8453, etc.) as the visible header label. The chain modal/options already use chain names, and the PR smoke confirmed the desktop Lend header displays 1. This is a small but user-visible regression; use the chain registry name with numeric id only as fallback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants