From 43ba82f1d417e77399d0aa84a6214ec9090a525b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 08:50:01 +0000 Subject: [PATCH 01/52] docs: add AGENTS.md with Cursor Cloud dev environment setup notes --- AGENTS.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4245d152d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +Euler Lite is a single **Nuxt 4 / Vue 3 (TypeScript)** frontend for the Euler DeFi lending +protocol. There is one service: the Nuxt app (SSR server + client). Standard commands live in +`README.md` ("Available Scripts") and `package.json`. + +### Node version +- The app requires **Node 24** (24.14.1 is installed via `nvm` and set as the default). The VM's + system `node` at `/exec-daemon/node` is Node 22, so `~/.bashrc` prepends the Node 24 bin to + `PATH`. New login shells already resolve `node -v` to 24.x β€” no action needed. If you spawn a + non-login/non-interactive shell and get Node 22, run `nvm use default` or prepend + `$HOME/.nvm/versions/node/v24.14.1/bin` to `PATH`. + +### Environment config (`.env`) +- The app reads runtime config from `.env` (gitignored). A working dev `.env` is created during + setup by copying `.env.example` and setting `NUXT_PUBLIC_APP_URL=http://localhost:3000` plus a + public RPC: `RPC_URL_1=https://ethereum-rpc.publicnode.com`. If `.env` is missing, recreate it + the same way β€” **at least one `RPC_URL_` is required** or the chain selector shows no + chains. Subgraph URLs for many chains are already present in `.env.example`. +- `NUXT_PUBLIC_APP_KIT_PROJECT_ID` (Reown/WalletConnect) is left empty; browsing/read flows work + without it, but live wallet connection needs a real project ID. +- Enabled chains are derived from `RPC_URL_` vars at server startup, so **restart + `npm run dev` after editing chain env vars** (they are not hot-reloaded). + +### Running / testing +- Dev server: `npm run dev` β†’ http://localhost:3000 (use a tmux session so it persists). +- Vault/market data (Explore page, vault details) loads from the upstream V3 API + the configured + RPC; some individual price/vault upstream calls may log `502`/timeout warnings without breaking + the page. +- Lint: `npm run lint` (a few pre-existing `no-explicit-any` warnings, 0 errors). +- Typecheck: `npm run typecheck`. +- Tests: `npm run test:run` (single pass; `npm run test` is watch mode). Test runs print lots of + `pino` warn/error JSON lines from exercised code paths β€” this is expected; check the final + vitest summary. +- A `pre-commit` hook (simple-git-hooks + lint-staged) runs `eslint --fix` on staged files. From 9d469e4ebe4cfebc176100e63b0032edf6507fde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 07:34:54 +0000 Subject: [PATCH 02/52] docs: document projected yield architecture --- docs/README.md | 7 ++ docs/intrinsic-apy.md | 2 + docs/pricing-system.md | 2 + docs/projected-yield.md | 161 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 docs/projected-yield.md diff --git a/docs/README.md b/docs/README.md index c554289d6..e3d56e376 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,13 @@ Welcome to the documentation for the Euler Lite project. This documentation is d - Pyth oracle simulation for price reads - SDK/V3-populated intrinsic APY for yield-bearing assets +### πŸ“ [Projected Yield](./projected-yield.md) + +- Utilization-aware supply and borrow rate projections +- USD-weighted multi-collateral snapshots +- Net APY, ROE, intrinsic yield, and reward breakdowns +- Transaction-batch layer consistency and fail-closed estimates + ### πŸ“Š [Portfolio Logic](./portfolio-logic.md) - Position discovery and categorization diff --git a/docs/intrinsic-apy.md b/docs/intrinsic-apy.md index ca26e8ee6..474713355 100644 --- a/docs/intrinsic-apy.md +++ b/docs/intrinsic-apy.md @@ -114,3 +114,5 @@ Intrinsic APY values rotate when the V3 backend's source providers update β€” ty V3 owns the provider list. Add the asset upstream in the V3 backend's intrinsic-APY adapter and it appears in `vault.intrinsicApy` here automatically the next time the snapshot warms. If a provider needs Lite-specific handling before it is available in V3, keep the data on an explicit current data path such as a labels payload field and wire it into the vault display code with tests. The preferred durable path is upstream into V3. + +For form previews, the intrinsic component is recomputed against projected market rates and included in the before/after contribution breakdown. See [Projected Yield](./projected-yield.md). diff --git a/docs/pricing-system.md b/docs/pricing-system.md index 942c04ac7..79579e2f8 100644 --- a/docs/pricing-system.md +++ b/docs/pricing-system.md @@ -77,3 +77,5 @@ Those calculations stay independent from market display prices. USD helpers return `undefined` when the SDK field is missing. UI wrappers use `toUsdAmount()` or `get*OrZero()` depending on whether the component needs to distinguish missing prices from true zero values. Use `toUsdAmount(undefined)` when the UI should show the token amount instead of a USD value. Use `getAssetUsdValueOrZero()` only in aggregate/list contexts where missing prices should not block rendering. + +Form-time Net APY and ROE previews use the same liability-context USD values to weight every collateral in a position. See [Projected Yield](./projected-yield.md) for the utilization simulation, completeness rules, and metric formulas. diff --git a/docs/projected-yield.md b/docs/projected-yield.md new file mode 100644 index 000000000..1c36d8070 --- /dev/null +++ b/docs/projected-yield.md @@ -0,0 +1,161 @@ +# Projected Yield + +Euler Lite previews how an operation can change Supply APY, Net APY, or ROE before the transaction is submitted. The projection combines: + +- post-operation vault utilization and interest rates; +- the position's USD-valued collateral and debt; +- intrinsic yield; and +- supply, borrow, collateral-qualified, and looping rewards. + +This is an estimate for form review, not a transaction simulation or a guaranteed future rate. On-chain utilization, prices, and reward eligibility can change before execution. + +## Architecture + +```text +Form inputs and simulated batch layer + β”‚ + β–Ό + cash / borrow / asset deltas + β”‚ + β–Ό +getProjectedRatesBatch() ──► EulerVaultLens projected rates + β”‚ + β–Ό +getCollateralApySnapshot() ─► USD-weighted collateral state + β”‚ + β–Ό + getProjectedYieldState() ─► total + contribution breakdown + β”‚ + β–Ό +ProjectedYieldSummaryRow / ProjectedYieldBreakdownModal +``` + +The main modules are: + +| Module | Responsibility | +|---|---| +| `utils/vault/apy.ts` | Projects supply and borrow rates from adjusted vault cash and borrows | +| `composables/usePositionCollateralApy.ts` | Resolves every collateral, applies form deltas, values it in USD, and builds a weighted snapshot | +| `composables/useLayeredVaults.ts` | Prefers the active transaction-batch simulation layer over the live vault registry | +| `utils/projected-yield.ts` | Calculates metric totals and contribution rows; merges before/after reward campaigns | +| `components/entities/vault/form/ProjectedYieldSummaryRow.vue` | Renders the form summary and breakdown triggers | +| `components/entities/vault/ProjectedYieldBreakdownModal.vue` | Shows rate changes, yield contributions, and campaign APRs | + +## Rate Projection + +`getProjectedRatesBatch()` accepts the current vault state plus signed deltas: + +```ts +interface ProjectedRatesRequest { + vaultAddress: string + currentCash: bigint + currentBorrows: bigint + cashDelta: bigint + borrowsDelta: bigint +} +``` + +It calls `EulerVaultLens.getVaultInterestRateModelInfo` with the adjusted cash and borrow values. Negative adjusted values are clamped to zero. A fully empty vault returns zero supply and borrow APY without an RPC call. + +Requests from sibling form watchers in the same event-loop turn are coalesced by chain and lens configuration. When an EVC address is available, the lens reads use one EVC batch; otherwise they fall back to individual `readContract` calls. + +Multiple requests for the same vault in one caller batch describe one atomic after-state. Their deltas are combined when their base state matches. This matters when a vault is both collateral and liability in the same operation. + +A failed lens query, missing execution context, inconsistent same-vault base state, or malformed result produces `null` for that rate. Consumers must treat any missing requested rate as an unavailable projection, not as `0%`. + +## Position Collateral Snapshots + +`usePositionCollateralApy().getCollateralApySnapshot(position, liabilityVault, options)` returns a `CollateralApySnapshot`. It: + +1. waits for vault and market-price enrichment; +2. verifies that every collateral expected by the position is resolved; +3. applies asset and utilization deltas; +4. projects rates for changed EVaults; +5. values each collateral with `getCollateralUsdValue(..., 'off-chain')` from the liability vault's pricing perspective; and +6. weights base APY, intrinsic APY, and supply reward APY by collateral USD value. + +The options distinguish position balance changes from market utilization changes: + +| Option | Meaning | +|---|---| +| `deltas[].assetsDelta` | Change to the position's collateral assets | +| `deltas[].cashDelta` | Change to vault cash; defaults to `assetsDelta` | +| `deltas[].projectRates` | Whether that collateral vault needs a projected utilization rate | +| `liabilityRateDelta` | Cash and borrow changes for the debt vault | + +For example, borrowing more usually sets `cashDelta = -amount` and `borrowsDelta = amount` on the liability vault. Repaying does the reverse. A collateral swap may change `assetsDelta` and vault cash independently. + +Snapshots are deliberately fail-closed. `isComplete` is `false` when a vault, expected collateral, positive collateral price, or requested rate cannot be resolved, or when market data does not become ready within 10 seconds. Forms hide the projection in that state instead of displaying a misleading zero. + +## Batch-Layer Consistency + +Transaction batching simulates each prefix of the batch and publishes the selected layer's vault entities through `activeLayerVaultsRef`. Snapshot resolution checks that layer before the live registry. + +This keeps a later form consistent with earlier queued operations. For example, a borrow added after a simulated deposit uses the deposit-adjusted collateral balance and vault utilization. Do not bypass `resolveLayeredVault()` with a direct registry read in projection code. + +## Metric Semantics + +`getProjectedYieldState(metric, inputs)` returns the total and four contributions: lending, borrowing, intrinsic yield, and rewards. + +Let: + +- `S` = supplied collateral value in USD; +- `B` = borrowed value in USD; +- `E = S - B` = equity; and +- `D` = `E` for ROE, otherwise `S` for Net APY and Supply APY. + +The contribution formulas are: + +```text +lending = S Γ— base supply APY / D +borrowing = -B Γ— base borrow APY / D +intrinsic = (S Γ— supply intrinsic APY - B Γ— borrow intrinsic APY) / D +rewards = (S Γ— supply reward APY + B Γ— borrow reward APY + + E Γ— looping reward APY) / D +total = lending + borrowing + intrinsic + rewards +``` + +APY inputs are percentage values, not decimal fractions. Call sites pass intrinsic components after compounding them with the corresponding market rate through `withProjectedVaultIntrinsicApy()` or the equivalent intrinsic helper. The per-user `enableIntrinsicApy` setting is respected while the snapshot is built. + +Non-finite inputs return `null`. A zero or negative denominator returns a zero breakdown. Callers that need to distinguish missing data must do so before invoking the helper. + +## Reward and Rate Breakdown + +`ProjectedYieldDetails` carries: + +- optional `before` and required `after` metric states; +- only the market-rate rows that changed; and +- reward campaigns from both states. + +Reward rows retain vault, collateral, action, provider, and reward-token identity. A newly applicable campaign shows only its projected APR; an existing `0%` campaign is preserved as a real before value; a campaign that stops applying shows a transition to `-`. + +`projectedYieldHasRewards()` checks both weighted reward contributions and campaign rows. The summary uses that result for the reward indicator. + +## Adding a Projection to a Form + +1. Define current and after-state token amounts as `bigint`; do not derive utilization deltas from rounded display values. +2. Resolve both snapshots when the operation changes a position. Pass `projectRates: true` only for collateral vaults whose cash changes. +3. Pass a `liabilityRateDelta` whenever debt-vault cash or borrows change. +4. Abort presentation unless every required snapshot and projected rate is complete. +5. Convert projected 27-decimal lens APYs with `nanoToValue(rate, 25)` to the percentage units used by the UI. +6. Build both metric states from the same collateral, debt, intrinsic, and reward inputs. +7. Merge campaign inputs with `mergeProjectedRewardCampaigns()` and preserve vault identity in rate rows. +8. Guard asynchronous recomputation with `createRaceGuard()` so stale input results cannot overwrite the latest estimate. + +Current consumers include lend deposit/withdraw/swap, borrow and borrow-more, multiply, collateral changes, refinance, and repay variants under `pages/` and `composables/repay/`. + +## Troubleshooting + +- **Projection stays hidden:** check `snapshot.isComplete`, the requested rate array, and whether positive collateral has a valid liability-context USD price. +- **Rate ignores an earlier batch item:** resolve the vault through `useLayeredVaults()` and verify the active simulated layer contains the vault. +- **Same vault is projected twice with no result:** both requests must use identical `currentCash` and `currentBorrows`; only their deltas may differ. +- **Headline and modal differ:** derive both from the same `ProjectedYieldState`; do not recalculate the headline with a separate APY helper. +- **Rewards look duplicated:** campaign identity must include the vault and `rewardCampaignKey()`, which includes action and collateral qualification. + +## Tests + +- `tests/utils/vault/projected-rates.test.ts` β€” rate batching, same-vault merging, deployment scoping, and failure results +- `tests/composables/usePositionCollateralApy.test.ts` β€” multi-collateral weighting, layer-aware reads, and incomplete snapshots +- `tests/utils/projected-yield.test.ts` β€” metric denominators, campaign transitions, and reward indicators +- `tests/composables/useLayeredVaults.test.ts` β€” simulated-vault precedence +- Form-specific tests under `tests/composables/` β€” operation deltas, race handling, and hidden projections on unavailable rates From d06facd23254896abf63a09d0e09948efab24738 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 07:36:11 +0000 Subject: [PATCH 03/52] docs: clarify projected rate breakdown rows --- docs/projected-yield.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/projected-yield.md b/docs/projected-yield.md index 1c36d8070..a57b1739c 100644 --- a/docs/projected-yield.md +++ b/docs/projected-yield.md @@ -124,7 +124,7 @@ Non-finite inputs return `null`. A zero or negative denominator returns a zero b `ProjectedYieldDetails` carries: - optional `before` and required `after` metric states; -- only the market-rate rows that changed; and +- market-rate rows relevant to the operation (the collateral snapshot helper filters unchanged rows); and - reward campaigns from both states. Reward rows retain vault, collateral, action, provider, and reward-token identity. A newly applicable campaign shows only its projected APR; an existing `0%` campaign is preserved as a real before value; a campaign that stops applying shows a transition to `-`. From 7806b257ee1def1b8ff464e4bfdf5c46776dbc70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 10:37:15 +0000 Subject: [PATCH 04/52] fix: keep failed projected-rate reads scoped to their request The non-EVC fallback awaited Promise.all over readContract, so a single failed vault read rejected the whole queued group and every caller waiting on that deployment. Catch per read and normalize to null, which matches the per-item failures the EVC batch path already reports. --- tests/utils/vault/projected-rates.test.ts | 55 +++++++++++++++++++++++ utils/vault/apy.ts | 44 +++++++++++++----- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/tests/utils/vault/projected-rates.test.ts b/tests/utils/vault/projected-rates.test.ts index 415a14789..b5b77f9db 100644 --- a/tests/utils/vault/projected-rates.test.ts +++ b/tests/utils/vault/projected-rates.test.ts @@ -26,6 +26,7 @@ describe('getProjectedRatesBatch', () => { beforeEach(() => { vi.useFakeTimers() + getProvider.mockImplementation((id: number) => ({ chainId: id })) chainId.value = 1 eulerLensAddresses.value = { vaultLens: '0x0000000000000000000000000000000000000010' } eulerCoreAddresses.value = { evc: '0x0000000000000000000000000000000000000020' } @@ -114,6 +115,60 @@ describe('getProjectedRatesBatch', () => { expect(batchLensCalls).not.toHaveBeenCalled() }) + it('normalizes reverted and transport-failed EVC batch items to null', async () => { + batchLensCalls.mockResolvedValue([ + { success: false, result: null }, + { success: false, result: null, transportError: true }, + { success: true, result: { queryFailure: true, interestRateInfo: [] } }, + ]) + + const projection = getProjectedRatesBatch([ + request('0x0000000000000000000000000000000000000001'), + request('0x0000000000000000000000000000000000000002'), + request('0x0000000000000000000000000000000000000003'), + ]) + + await vi.runAllTimersAsync() + + expect(await projection).toEqual([null, null, null]) + }) + + it('keeps a failed fallback read scoped to its own request', async () => { + eulerCoreAddresses.value = {} as { evc: string } + const readContract = vi.fn() + .mockRejectedValueOnce(new Error('rpc failed')) + .mockResolvedValueOnce({ + queryFailure: false, + interestRateInfo: [{ supplyAPY: 7n, borrowAPY: 17n }], + }) + getProvider.mockReturnValue({ readContract } as never) + + const first = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000001')]) + const second = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000002')]) + + await vi.runAllTimersAsync() + const [firstResult, secondResult] = await Promise.all([first, second]) + + expect(batchLensCalls).not.toHaveBeenCalled() + expect(readContract).toHaveBeenCalledTimes(2) + expect(firstResult).toEqual([null]) + expect(secondResult).toEqual([{ supplyAPY: 7n, borrowAPY: 17n }]) + }) + + it('rejects every caller in a deployment group when the provider cannot be resolved', async () => { + getProvider.mockImplementation(() => { + throw new Error('unsupported chain') + }) + + const first = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000001')]) + const second = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000002')]) + const settled = Promise.allSettled([first, second]) + + await vi.runAllTimersAsync() + + expect((await settled).map(result => result.status)).toEqual(['rejected', 'rejected']) + }) + it('keeps queued projections scoped to their enqueue-time chain deployment', async () => { const first = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000001')]) diff --git a/utils/vault/apy.ts b/utils/vault/apy.ts index 29647638a..f07c4dd2e 100644 --- a/utils/vault/apy.ts +++ b/utils/vault/apy.ts @@ -2,6 +2,7 @@ import type { Address, PublicClient } from 'viem' import { eulerVaultLensABI } from '~/entities/euler/abis' import { getEulerSdk } from '~/composables/useEulerSdk' import { batchLensCalls } from '~/utils/multicall' +import { logger } from '~/utils/logger' export interface ProjectedRates { supplyAPY: bigint // 27 decimals @@ -125,16 +126,32 @@ const executeProjectedRatesBatch = async ( return results } - const fallbackResults = await Promise.all(calls.map(async call => - provider.readContract({ - address: context.vaultLens as Address, - abi: eulerVaultLensABI, - functionName: 'getVaultInterestRateModelInfo', - authorizationList: undefined, - args: call.args as [Address, bigint[], bigint[]], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic lens contract return - }) as Promise>, - )) + // A read failure stays scoped to its own request, mirroring the per-item + // failures the EVC path reports. One unhealthy vault read must not discard + // the projections queued for every other vault sharing this batch. + const fallbackResults = await Promise.all(calls.map(async (call, activeIndex) => { + try { + return await provider.readContract({ + address: context.vaultLens as Address, + abi: eulerVaultLensABI, + functionName: 'getVaultInterestRateModelInfo', + authorizationList: undefined, + args: call.args as [Address, bigint[], bigint[]], + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic lens contract return + }) as Record + } + catch (err) { + logger.warn( + { + ctx: 'getProjectedRatesBatch', + vault: active[activeIndex]?.request.vaultAddress, + err, + }, + 'projected rate lens read failed', + ) + return null + } + })) active.forEach((item, activeIndex) => { results[item.index] = parseProjectedRatesResult(fallbackResults[activeIndex]) @@ -238,7 +255,12 @@ const flushProjectedRatesBatches = async () => { * Coalesce projection requests created by sibling form watchers in the same * render turn. Position forms often project supply and borrow legs in separate * composables; collecting them until the next task keeps that recompute to one - * EVC lens batch without coupling those composables together. + * coalesced EVC lens batch (which `batchLensCalls()` chunks at 25 calls) + * without coupling those composables together. + * + * Rates that cannot be produced resolve to `null` per request. Only a failure + * to obtain the SDK provider rejects, and it rejects every caller queued for + * that deployment group. */ export const getProjectedRatesBatch = ( requests: ProjectedRatesRequest[], From ae3584391101d94ee9a662cb04fd747977e131b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 10:37:16 +0000 Subject: [PATCH 05/52] docs: describe the real projected-rate failure boundaries Per-request failures normalize to null on both transports; only provider resolution rejects, and it rejects the whole deployment group. Also note that batched EVC lens calls are chunked at 25 rather than always one. --- docs/projected-yield.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/projected-yield.md b/docs/projected-yield.md index a57b1739c..7af23788b 100644 --- a/docs/projected-yield.md +++ b/docs/projected-yield.md @@ -57,11 +57,25 @@ interface ProjectedRatesRequest { It calls `EulerVaultLens.getVaultInterestRateModelInfo` with the adjusted cash and borrow values. Negative adjusted values are clamped to zero. A fully empty vault returns zero supply and borrow APY without an RPC call. -Requests from sibling form watchers in the same event-loop turn are coalesced by chain and lens configuration. When an EVC address is available, the lens reads use one EVC batch; otherwise they fall back to individual `readContract` calls. +Requests from sibling form watchers in the same event-loop turn are coalesced by chain and lens configuration. When an EVC address is available, the lens reads go through batched EVC simulation calls, which `batchLensCalls()` chunks at 25 calls per request; otherwise they fall back to individual `readContract` calls. Multiple requests for the same vault in one caller batch describe one atomic after-state. Their deltas are combined when their base state matches. This matters when a vault is both collateral and liability in the same operation. -A failed lens query, missing execution context, inconsistent same-vault base state, or malformed result produces `null` for that rate. Consumers must treat any missing requested rate as an unavailable projection, not as `0%`. +### Failure Contract + +Per-request failures resolve to `null` for that rate on both transports: + +| Cause | Result | +|---|---| +| Missing chain id or vault-lens address | `null` for every request in the call | +| Inconsistent base state across same-vault requests | `null` for the conflicting vault's requests | +| Reverted, transport-suppressed, or short EVC batch item | `null` for that request | +| Failed `readContract` on the non-EVC fallback | `null` for that request | +| `queryFailure` or empty `interestRateInfo` in the result | `null` for that request | + +Two failures happen before any per-request work and therefore reject the returned promise instead of resolving with `null`: a rejected `getEulerSdk()` and a throwing `providerService.getProvider()`. Because the queue groups callers by chain, lens, and EVC address, that rejection reaches every caller waiting on the affected deployment group β€” those callers share the provider that could not be resolved, so none of them has a projectable state. Call sites must keep the projection work inside `try`/`catch` and treat a rejection the same way they treat `null`. + +Consumers must treat any missing requested rate as an unavailable projection, not as `0%`. `areProjectedRatesComplete()` (array-level) and `getCollateralApySnapshot()` (snapshot-level) already enforce that. ## Position Collateral Snapshots @@ -136,7 +150,7 @@ Reward rows retain vault, collateral, action, provider, and reward-token identit 1. Define current and after-state token amounts as `bigint`; do not derive utilization deltas from rounded display values. 2. Resolve both snapshots when the operation changes a position. Pass `projectRates: true` only for collateral vaults whose cash changes. 3. Pass a `liabilityRateDelta` whenever debt-vault cash or borrows change. -4. Abort presentation unless every required snapshot and projected rate is complete. +4. Abort presentation unless every required snapshot and projected rate is complete, and catch rejections from the rate queue so a provider failure hides the estimate instead of surfacing an unhandled rejection. 5. Convert projected 27-decimal lens APYs with `nanoToValue(rate, 25)` to the percentage units used by the UI. 6. Build both metric states from the same collateral, debt, intrinsic, and reward inputs. 7. Merge campaign inputs with `mergeProjectedRewardCampaigns()` and preserve vault identity in rate rows. @@ -149,13 +163,14 @@ Current consumers include lend deposit/withdraw/swap, borrow and borrow-more, mu - **Projection stays hidden:** check `snapshot.isComplete`, the requested rate array, and whether positive collateral has a valid liability-context USD price. - **Rate ignores an earlier batch item:** resolve the vault through `useLayeredVaults()` and verify the active simulated layer contains the vault. - **Same vault is projected twice with no result:** both requests must use identical `currentCash` and `currentBorrows`; only their deltas may differ. +- **Every form on the page loses its projection at once:** look for a rejected provider or SDK load rather than a per-vault lens failure; those reject the whole deployment group. - **Headline and modal differ:** derive both from the same `ProjectedYieldState`; do not recalculate the headline with a separate APY helper. - **Rewards look duplicated:** campaign identity must include the vault and `rewardCampaignKey()`, which includes action and collateral qualification. ## Tests -- `tests/utils/vault/projected-rates.test.ts` β€” rate batching, same-vault merging, deployment scoping, and failure results +- `tests/utils/vault/projected-rates.test.ts` β€” rate batching, same-vault merging, deployment scoping, per-request failure normalization, and the provider-resolution rejection boundary - `tests/composables/usePositionCollateralApy.test.ts` β€” multi-collateral weighting, layer-aware reads, and incomplete snapshots - `tests/utils/projected-yield.test.ts` β€” metric denominators, campaign transitions, and reward indicators - `tests/composables/useLayeredVaults.test.ts` β€” simulated-vault precedence -- Form-specific tests under `tests/composables/` β€” operation deltas, race handling, and hidden projections on unavailable rates +- Form-specific tests under `tests/composables/` β€” operation deltas, race handling, and hidden projections on both unavailable and rejected rates From 2b7a0cca0a85984b8ecb30b6318205f4fa346263 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 11:04:19 +0000 Subject: [PATCH 06/52] Revert "fix: keep failed projected-rate reads scoped to their request" This reverts commit 7806b257ee1def1b8ff464e4bfdf5c46776dbc70. --- tests/utils/vault/projected-rates.test.ts | 55 ----------------------- utils/vault/apy.ts | 44 +++++------------- 2 files changed, 11 insertions(+), 88 deletions(-) diff --git a/tests/utils/vault/projected-rates.test.ts b/tests/utils/vault/projected-rates.test.ts index b5b77f9db..415a14789 100644 --- a/tests/utils/vault/projected-rates.test.ts +++ b/tests/utils/vault/projected-rates.test.ts @@ -26,7 +26,6 @@ describe('getProjectedRatesBatch', () => { beforeEach(() => { vi.useFakeTimers() - getProvider.mockImplementation((id: number) => ({ chainId: id })) chainId.value = 1 eulerLensAddresses.value = { vaultLens: '0x0000000000000000000000000000000000000010' } eulerCoreAddresses.value = { evc: '0x0000000000000000000000000000000000000020' } @@ -115,60 +114,6 @@ describe('getProjectedRatesBatch', () => { expect(batchLensCalls).not.toHaveBeenCalled() }) - it('normalizes reverted and transport-failed EVC batch items to null', async () => { - batchLensCalls.mockResolvedValue([ - { success: false, result: null }, - { success: false, result: null, transportError: true }, - { success: true, result: { queryFailure: true, interestRateInfo: [] } }, - ]) - - const projection = getProjectedRatesBatch([ - request('0x0000000000000000000000000000000000000001'), - request('0x0000000000000000000000000000000000000002'), - request('0x0000000000000000000000000000000000000003'), - ]) - - await vi.runAllTimersAsync() - - expect(await projection).toEqual([null, null, null]) - }) - - it('keeps a failed fallback read scoped to its own request', async () => { - eulerCoreAddresses.value = {} as { evc: string } - const readContract = vi.fn() - .mockRejectedValueOnce(new Error('rpc failed')) - .mockResolvedValueOnce({ - queryFailure: false, - interestRateInfo: [{ supplyAPY: 7n, borrowAPY: 17n }], - }) - getProvider.mockReturnValue({ readContract } as never) - - const first = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000001')]) - const second = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000002')]) - - await vi.runAllTimersAsync() - const [firstResult, secondResult] = await Promise.all([first, second]) - - expect(batchLensCalls).not.toHaveBeenCalled() - expect(readContract).toHaveBeenCalledTimes(2) - expect(firstResult).toEqual([null]) - expect(secondResult).toEqual([{ supplyAPY: 7n, borrowAPY: 17n }]) - }) - - it('rejects every caller in a deployment group when the provider cannot be resolved', async () => { - getProvider.mockImplementation(() => { - throw new Error('unsupported chain') - }) - - const first = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000001')]) - const second = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000002')]) - const settled = Promise.allSettled([first, second]) - - await vi.runAllTimersAsync() - - expect((await settled).map(result => result.status)).toEqual(['rejected', 'rejected']) - }) - it('keeps queued projections scoped to their enqueue-time chain deployment', async () => { const first = getProjectedRatesBatch([request('0x0000000000000000000000000000000000000001')]) diff --git a/utils/vault/apy.ts b/utils/vault/apy.ts index f07c4dd2e..29647638a 100644 --- a/utils/vault/apy.ts +++ b/utils/vault/apy.ts @@ -2,7 +2,6 @@ import type { Address, PublicClient } from 'viem' import { eulerVaultLensABI } from '~/entities/euler/abis' import { getEulerSdk } from '~/composables/useEulerSdk' import { batchLensCalls } from '~/utils/multicall' -import { logger } from '~/utils/logger' export interface ProjectedRates { supplyAPY: bigint // 27 decimals @@ -126,32 +125,16 @@ const executeProjectedRatesBatch = async ( return results } - // A read failure stays scoped to its own request, mirroring the per-item - // failures the EVC path reports. One unhealthy vault read must not discard - // the projections queued for every other vault sharing this batch. - const fallbackResults = await Promise.all(calls.map(async (call, activeIndex) => { - try { - return await provider.readContract({ - address: context.vaultLens as Address, - abi: eulerVaultLensABI, - functionName: 'getVaultInterestRateModelInfo', - authorizationList: undefined, - args: call.args as [Address, bigint[], bigint[]], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic lens contract return - }) as Record - } - catch (err) { - logger.warn( - { - ctx: 'getProjectedRatesBatch', - vault: active[activeIndex]?.request.vaultAddress, - err, - }, - 'projected rate lens read failed', - ) - return null - } - })) + const fallbackResults = await Promise.all(calls.map(async call => + provider.readContract({ + address: context.vaultLens as Address, + abi: eulerVaultLensABI, + functionName: 'getVaultInterestRateModelInfo', + authorizationList: undefined, + args: call.args as [Address, bigint[], bigint[]], + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic lens contract return + }) as Promise>, + )) active.forEach((item, activeIndex) => { results[item.index] = parseProjectedRatesResult(fallbackResults[activeIndex]) @@ -255,12 +238,7 @@ const flushProjectedRatesBatches = async () => { * Coalesce projection requests created by sibling form watchers in the same * render turn. Position forms often project supply and borrow legs in separate * composables; collecting them until the next task keeps that recompute to one - * coalesced EVC lens batch (which `batchLensCalls()` chunks at 25 calls) - * without coupling those composables together. - * - * Rates that cannot be produced resolve to `null` per request. Only a failure - * to obtain the SDK provider rejects, and it rejects every caller queued for - * that deployment group. + * EVC lens batch without coupling those composables together. */ export const getProjectedRatesBatch = ( requests: ProjectedRatesRequest[], From 5ec460b5d99e14fdd81b7a3b6d083bc5150b191d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 11:06:39 +0000 Subject: [PATCH 07/52] docs: document projected-rate rejection paths as they stand Keeps the PR documentation-only: the non-EVC fallback still rejects the whole deployment group on a failed read, so record that boundary and the resulting transport asymmetry instead of promising per-request nulls. --- docs/projected-yield.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/projected-yield.md b/docs/projected-yield.md index 7af23788b..c5e37f2a7 100644 --- a/docs/projected-yield.md +++ b/docs/projected-yield.md @@ -63,19 +63,25 @@ Multiple requests for the same vault in one caller batch describe one atomic aft ### Failure Contract -Per-request failures resolve to `null` for that rate on both transports: +`getProjectedRatesBatch()` reports failures two different ways, and call sites must handle both. These causes resolve to `null` for the affected rate: | Cause | Result | |---|---| | Missing chain id or vault-lens address | `null` for every request in the call | | Inconsistent base state across same-vault requests | `null` for the conflicting vault's requests | | Reverted, transport-suppressed, or short EVC batch item | `null` for that request | -| Failed `readContract` on the non-EVC fallback | `null` for that request | | `queryFailure` or empty `interestRateInfo` in the result | `null` for that request | -Two failures happen before any per-request work and therefore reject the returned promise instead of resolving with `null`: a rejected `getEulerSdk()` and a throwing `providerService.getProvider()`. Because the queue groups callers by chain, lens, and EVC address, that rejection reaches every caller waiting on the affected deployment group β€” those callers share the provider that could not be resolved, so none of them has a projectable state. Call sites must keep the projection work inside `try`/`catch` and treat a rejection the same way they treat `null`. +These causes reject the returned promise instead: -Consumers must treat any missing requested rate as an unavailable projection, not as `0%`. `areProjectedRatesComplete()` (array-level) and `getCollateralApySnapshot()` (snapshot-level) already enforce that. +| Cause | Result | +|---|---| +| Rejected `getEulerSdk()` or throwing `providerService.getProvider()` | rejects every caller queued for that deployment group | +| Any failed `readContract` on the non-EVC fallback path | rejects every caller queued for that deployment group | + +The rejection scope follows the queue: batches are grouped by chain, lens, and EVC address, and a group-level throw reaches every caller in that group. That is proportionate for a provider failure, because none of those callers has a reachable chain. It is not proportionate for the fallback path, which awaits `Promise.all` over per-vault `readContract` calls: one unhealthy vault read discards the projections of unrelated callers whose own reads succeeded. It also makes the two transports asymmetric β€” the same lens failure yields `null` under EVC batching but a rejection when no EVC address is configured. Normalizing the fallback to per-request `null` values would remove both quirks; until then, do not assume that a failed lens read resolves to `null`. + +So a call site must both check for `null` rates and wrap the projection in `try`/`catch`, treating a rejection exactly like a `null` rate. Any missing rate is an unavailable projection, never `0%`. `areProjectedRatesComplete()` covers the array-level check and `getCollateralApySnapshot()` catches rejections internally, returning an incomplete snapshot. ## Position Collateral Snapshots @@ -150,7 +156,7 @@ Reward rows retain vault, collateral, action, provider, and reward-token identit 1. Define current and after-state token amounts as `bigint`; do not derive utilization deltas from rounded display values. 2. Resolve both snapshots when the operation changes a position. Pass `projectRates: true` only for collateral vaults whose cash changes. 3. Pass a `liabilityRateDelta` whenever debt-vault cash or borrows change. -4. Abort presentation unless every required snapshot and projected rate is complete, and catch rejections from the rate queue so a provider failure hides the estimate instead of surfacing an unhandled rejection. +4. Abort presentation unless every required snapshot and projected rate is complete, and catch rejections from the rate queue so a group-level failure hides the estimate instead of surfacing an unhandled rejection. 5. Convert projected 27-decimal lens APYs with `nanoToValue(rate, 25)` to the percentage units used by the UI. 6. Build both metric states from the same collateral, debt, intrinsic, and reward inputs. 7. Merge campaign inputs with `mergeProjectedRewardCampaigns()` and preserve vault identity in rate rows. @@ -163,13 +169,13 @@ Current consumers include lend deposit/withdraw/swap, borrow and borrow-more, mu - **Projection stays hidden:** check `snapshot.isComplete`, the requested rate array, and whether positive collateral has a valid liability-context USD price. - **Rate ignores an earlier batch item:** resolve the vault through `useLayeredVaults()` and verify the active simulated layer contains the vault. - **Same vault is projected twice with no result:** both requests must use identical `currentCash` and `currentBorrows`; only their deltas may differ. -- **Every form on the page loses its projection at once:** look for a rejected provider or SDK load rather than a per-vault lens failure; those reject the whole deployment group. +- **Every form on the page loses its projection at once:** look for a group-level rejection β€” a failed SDK or provider lookup, or a failed lens read on a deployment with no EVC address β€” rather than a per-vault `null`. - **Headline and modal differ:** derive both from the same `ProjectedYieldState`; do not recalculate the headline with a separate APY helper. - **Rewards look duplicated:** campaign identity must include the vault and `rewardCampaignKey()`, which includes action and collateral qualification. ## Tests -- `tests/utils/vault/projected-rates.test.ts` β€” rate batching, same-vault merging, deployment scoping, per-request failure normalization, and the provider-resolution rejection boundary +- `tests/utils/vault/projected-rates.test.ts` β€” rate batching, same-vault merging, deployment scoping, and `null` rate results; the group-level rejection paths in the failure contract are not covered here - `tests/composables/usePositionCollateralApy.test.ts` β€” multi-collateral weighting, layer-aware reads, and incomplete snapshots - `tests/utils/projected-yield.test.ts` β€” metric denominators, campaign transitions, and reward indicators - `tests/composables/useLayeredVaults.test.ts` β€” simulated-vault precedence From 436130067b1fde1f4114eb26090b5cd8eaef68e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:21:12 +0000 Subject: [PATCH 08/52] chore(deps): bump valibot from 1.4.1 to 1.4.2 Bumps [valibot](https://github.com/open-circle/valibot) from 1.4.1 to 1.4.2. - [Release notes](https://github.com/open-circle/valibot/releases) - [Commits](https://github.com/open-circle/valibot/compare/v1.4.1...v1.4.2) --- updated-dependencies: - dependency-name: valibot dependency-version: 1.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee41d368c..a9ed81e79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18653,9 +18653,9 @@ "license": "MIT" }, "node_modules/valibot": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.1.tgz", - "integrity": "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", "license": "MIT", "peerDependencies": { "typescript": ">=5" From 1534c447f6525787b5604c704bfdd309012c74eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 07:35:45 +0000 Subject: [PATCH 09/52] docs: document V3-backed activity feed architecture Cover vault and portfolio activity surfaces, availability gating, category defaults, display filtering, liquidation enrichment, and proxy allowlist constraints verified against the current source. --- docs/README.md | 7 ++ docs/activity-feed.md | 189 ++++++++++++++++++++++++++++++++++++++++ docs/portfolio-logic.md | 1 + docs/sdk-integration.md | 5 ++ 4 files changed, 202 insertions(+) create mode 100644 docs/activity-feed.md diff --git a/docs/README.md b/docs/README.md index c554289d6..64da88246 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,13 @@ Welcome to the documentation for the Euler Lite project. This documentation is d - SDK TransactionPlan architecture and composite operations - EVC batching and Permit2 integration - Sub-accounts and position isolation +- Simulation performance tuning and batch-cart slot-hint / account prefetch + +### πŸ“œ [Activity Feed](./activity-feed.md) + +- V3-backed vault and portfolio activity surfaces +- Availability gating, category defaults, and display event-type allowlists +- Liquidation enrichment, transaction grouping, and proxy allowlist constraints ### 🧩 [SDK Integration](./sdk-integration.md) diff --git a/docs/activity-feed.md b/docs/activity-feed.md new file mode 100644 index 000000000..95c497227 --- /dev/null +++ b/docs/activity-feed.md @@ -0,0 +1,189 @@ +# Activity Feed + +This document describes how Euler Lite loads, filters, enriches, and renders protocol activity for vault overviews and the portfolio Activity tab. + +## Intent + +Activity is a V3-backed history surface. Lite does not invent an activity indexer: it calls the Euler V2 SDK `activityService`, which talks to upstream V3 through the same-origin `/api/internal/v3` proxy. Lite owns UI availability gating, display filtering, liquidation enrichment, and presentation. + +## Architecture + +``` +Vault overview accordion ─┐ +Portfolio /activity tab ── + β–Ό + useActivityAvailability + (V3 chain gate + SDK capabilities) + β–Ό + ActivityFeed.vue + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό β–Ό + useActivityFeed useActivityLiquidationDetails activity-display.ts + (events pages) (/v3/liquidations enrich) (filters, labels, groups) + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό + getEulerSdkForChain β†’ activityService + β–Ό + /api/internal/v3 β†’ upstream V3 +``` + +Two scopes: + +| Scope | SDK call | Upstream path | +| ----- | -------- | ------------- | +| Account | `fetchAccountActivityEvents` | `GET /v3/activity/accounts/0x…/events` | +| Vault | `fetchVaultActivityEvents` | `GET /v3/activity/vaults/{chainId}/0x…/events?vaultType=…` | +| Liquidation enrich | `fetchLiquidations` | `GET /v3/liquidations` | + +There are no dedicated Nuxt activity routes beyond the shared V3 proxy allowlist in `server/utils/v3-proxy.ts`. + +## UI surfaces + +| Surface | Path | Scope | +| ------- | ---- | ----- | +| EVK / Earn / Securitize vault overview | `VaultOverviewBlockActivity.vue` | Vault (`vaultType` = `evk` / `earn` / `securitize`) | +| Portfolio Activity tab | `pages/portfolio/activity.vue` | Account (`useEffectiveAddress()` β€” spy address when spy mode is active) | + +Vault accordion mounts closed by default. It stays hidden until availability is known (or the capability check fails and retry UI is shown). Runtime `coverage.status === 'unsupported'` with no category filter selected hides the whole section / portfolio tab. + +## Availability gating + +`useActivityAvailability(scope, chainId)`: + +1. Rejects invalid chain ids (`invalid-chain`). +2. Requires `useV3ChainGate().isV3EnabledForChain` (`v3-disabled` when gated off). +3. Reads `activityService.getCapabilities()` and `getScopeSupport(...)`. +4. Treats scope support `unknown` as requestable β€” response coverage is authoritative. Only explicit `unsupported` hides the surface. + +`shouldRender` is true when supported **or** when the capability check failed (`capability-check-failed`), so users can retry instead of losing the section silently. + +Portfolio tab visibility is coordinated through `usePortfolioActivityRuntimeSupport` (`useState` keyed by `owner:chainId`) between `pages/portfolio.vue` and `pages/portfolio/activity.vue`. + +## Feed loading + +`useActivityFeed({ scope, enabled, categories, limit = 25 })`: + +- Always sends scope-specific `eventTypes` from `getDisplayActivityEventTypes`. +- Omits `categories` from the request when the UI selection is empty (= union of chip categories, not β€œevery upstream category”). +- Dedupes pages by `event.id` (`mergeActivityEvents`). +- Stale head: `ACTIVITY_QUERY_STALE_TIME_MS` = 60s (`utils/sdk-query-policy.ts`). Reopening a stale feed refreshes while keeping last-good rows. +- Invalidates via `subscribeToSdkQueryInvalidations` on `queryAccountActivityEvents` / `queryVaultActivityEvents` (both `invalidateAfterTx: true`). +- Auto display-fill: up to **3** append pages when client filtering empties the visible set (`AUTO_DISPLAY_FILL_PAGE_LIMIT`). +- Pagination guard: append throws if `nextCursor` does not advance. +- Mid-flight context changes are discarded via `activeRequestId` + `buildActivityFeedContextKey`. + +Returned state flags worth knowing: `hasColdError` (error + no rows), `hasStaleError` (error + retained rows), `isPartial` / `isSyncing` / `isUnsupported` from coverage meta. + +## Filters and defaults + +### Vault + +`getVaultActivityFilterOptions` / `getDefaultVaultActivityFilter`: + +| Vault type | Default chip | Categories | +| ---------- | ------------ | ---------- | +| `evk` | `lending-borrowing` | lending + borrowing; also governance; liquidations | +| `earn` | `lending` | lending; governance (no liquidations chip) | +| `securitize` | `lending` | lending; governance | + +**Default is not β€œAll”.** Opening a vault Activity section fetches lending(+borrowing) first. Selecting nothing (β€œAll”) resolves to the union of that vault type’s option categories. + +Borrowability of the live vault does not remove historical borrowing filters. + +### Portfolio (account) + +- Unfiltered query categories: `lending`, `borrowing`, `liquidations` (`getAccountActivityCategories`). +- Visible chips: **liquidations only** (`getAccountActivityFilterOptions`). Lending/borrowing remain in the All query; verbs and tx grouping communicate them. +- The SDK `account` category is deliberately omitted β€” none of its event types are displayed on Lite. +- Categories present in label maps but not queried here: `swaps`, `rewards`, and account-scope `governance`. + +## Display event-type allowlists + +`getDisplayActivityEventTypes` + `filterActivityEventsForDisplay` keep only Lite-relevant types. + +**Account:** `deposit`, `withdraw`, `borrow`, `repay`, `pull_debt`, `liquidation`. + +**EVK vault:** lending/borrowing ops plus `transfer`, `debt_socialized`, `pull_debt`, `liquidation`, `approval`, `balance_forwarder_status`, `convert_fees`, and governance setters (`set_caps`, `set_ltv`, hooks, IRM, fees, …). + +**Earn / Securitize:** deposit/withdraw/transfer plus their reallocation / queue / governance ops (see `VAULT_ACTIVITY_EVENT_TYPES` in `utils/activity-display.ts`). + +Explicitly excluded noise (covered by tests): `interest_accrued`, `accrue_interest`, `mint`, `burn`, earn `update_last_total_assets` / `update_lost_assets`. + +### Client-side pairing rules + +After the allowlist, the feed always runs with `hideZeroLiquidations: true` and also: + +1. Drops **zero-value liquidations** (both assets + collateral `amountRaw` are `0`; malformed values are kept). +2. Drops **violator `repay`** events that match a same-tx liquidation (same account as violator + matching assets amount). Liquidator repayments are kept. +3. Drops **shadow `transfer`** of shares that pair with deposit/withdraw/liquidation/reallocation in the same tx (same share address + amountRaw). + +Pairing re-runs on the merged raw event list, so a shadow transfer on page 1 can disappear after a later page brings its paired deposit. + +## Liquidation enrichment + +`useActivityLiquidationDetails({ events })` is a second, fail-soft pass: + +- Groups events by `chainId:vault`, windows min/max event unix Β± 1s. +- Pages `/v3/liquidations` at limit 100, max **3** pages. +- Join key: `chainId:txHash:vault:violator:collateral:repayAssets` (lowercased). Event payload falls back to account / collateral asset / assets amountRaw. +- Fetch failures roll back the covered-window claim; rows stay unenriched with no feed-level error. + +Display prefers `repayAssetsUsd`, converted collateral (+ symbol), `collateralAssetsUsd`, and signed `bonusUsd` (with unit-of-account valuation fallback). + +### Grouping + +- **Portfolio:** `groupActivityEventsByTransaction` β†’ `chainId:txHash`. Header is `"Liquidation transaction"` when any event is a liquidation, else `"Transaction"`. Groups collapse after 3 events. +- **Vault:** one synthetic group per event (no tx bundling). + +## Proxy and ops constraints + +Allowlisted GET patterns only (`server/utils/v3-proxy.ts`): + +- `/v3/activity/accounts/0x[40hex]/events` +- `/v3/activity/vaults/[chainId]/0x[40hex]/events` +- `/v3/liquidations` + +Anything else 404s at the edge. Proxy logs keep chain / vault / categories / eventTypes / safe ranges and intentionally omit account / violator / liquidator addresses. Backoff keys redact owner/vault path segments. + +## Fail modes + +| Condition | Behavior | +| --------- | -------- | +| V3 disabled / invalid chain / unsupported scope | Hide surface | +| Capability check throws | Keep surface + retry | +| Cold feed error | Error card + retry | +| Refresh error with rows | Stale warning; keep last events | +| Liquidation enrich failure | Silent; rows unenriched | +| Coverage `unsupported` + All filters | Hide tab / vault section | +| Coverage `unsupported` + specific chip | β€œNot available for selected categories” | +| Coverage `partial` / `syncing` | Banners / empty messaging | + +## Common pitfalls + +1. Vault default filter β‰  All β€” documenting β€œshows every category on open” is wrong. +2. Portfolio chip row β‰  query set β€” All still fetches lending + borrowing + liquidations. +3. Event-type filtering is both a request `eventTypes` list and a client allowlist. +4. Liquidation USD/bonus comes from a separate endpoint; absence is expected and silent. +5. Account feed groups by transaction; vault feed does not. +6. `ActivityAddress` supports `linkKind: 'spy'`, but current display helpers only emit `explorer` / `vault` links. + +## Files + +| File | Purpose | +| ---- | ------- | +| `composables/useActivityFeed.ts` | Paged event loading, stale refresh, display-fill | +| `composables/useActivityAvailability.ts` | Capability / scope gating | +| `composables/useActivityLiquidationDetails.ts` | Fail-soft `/v3/liquidations` enrichment | +| `composables/useActivityNowMs.ts` | Shared 60s clock for relative timestamps | +| `composables/usePortfolioActivityRuntimeSupport.ts` | Portfolio tab visibility coordination | +| `utils/activity-display.ts` | Filters, allowlists, labels, grouping, liquidation display | +| `components/entities/activity/ActivityFeed.vue` | Orchestrator | +| `components/entities/activity/ActivityEventRow.vue` | Row / expand / registry metadata | +| `components/entities/activity/ActivityCategoryFilters.vue` | All + multi-select chips | +| `components/entities/activity/ActivityAddress.vue` | Explorer / vault / spy links | +| `components/entities/vault/overview/VaultOverviewBlockActivity.vue` | Vault accordion host | +| `pages/portfolio/activity.vue` | Account feed page | +| `server/utils/v3-proxy.ts` | Allowlist + log redaction | +| `utils/sdk-query-policy.ts` | 60s stale + post-tx invalidation | diff --git a/docs/portfolio-logic.md b/docs/portfolio-logic.md index 9dc9a8927..4d1b42e1e 100644 --- a/docs/portfolio-logic.md +++ b/docs/portfolio-logic.md @@ -349,6 +349,7 @@ After each fetch, `updateBalances()` schedules a follow-up run if its inputs (ch - [Pricing System](./pricing-system.md) β€” Full pricing architecture details - [Pyth Oracle Handling](./pyth-oracle-handling.md) β€” How Pyth oracles affect position loading - [Vault Labels & Verification](./vault-labels-and-verification.md) β€” How vault verification affects position visibility +- [Activity Feed](./activity-feed.md) β€” Portfolio and vault activity history (V3-backed) ### Reactive Update Triggers diff --git a/docs/sdk-integration.md b/docs/sdk-integration.md index 5afee720d..f0047de9c 100644 --- a/docs/sdk-integration.md +++ b/docs/sdk-integration.md @@ -146,6 +146,11 @@ export const SDK_QUERY_POLICY = { // authorization mid-flow), so they take short windows + post-tx eviction. queryGetPosition: { staleTimeMs: MINUTE, formStaleTimeMs: 15 * SECOND, invalidateAfterTx: true }, queryGetAuthorization: { staleTimeMs: MINUTE, formStaleTimeMs: 15 * SECOND, invalidateAfterTx: true }, + + // Activity history (V3 via /api/internal/v3). See docs/activity-feed.md. + queryAccountActivityEvents: { staleTimeMs: ACTIVITY_QUERY_STALE_TIME_MS, invalidateAfterTx: true }, + queryVaultActivityEvents: { staleTimeMs: ACTIVITY_QUERY_STALE_TIME_MS, invalidateAfterTx: true }, + queryLiquidations: { staleTimeMs: ACTIVITY_QUERY_STALE_TIME_MS, invalidateAfterTx: true }, } ``` From c52b5842445a84cb9562ac132c69191bbf34e1cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 07:35:45 +0000 Subject: [PATCH 10/52] docs: document batch cart slot-hint and account prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the form β†’ batchPrefetchState β†’ useTxBatch handoff, background priming, and owner-address simulation freshness so the recent batch performance path matches the code. --- docs/transaction-building.md | 38 +++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/transaction-building.md b/docs/transaction-building.md index 78a3a5d53..8273bd1e9 100644 --- a/docs/transaction-building.md +++ b/docs/transaction-building.md @@ -116,6 +116,7 @@ Wired into: - `composables/position/useCollateralForm.ts` - `composables/useSwapPageLogic.ts` - `pages/lend/[vault]/index.vue` (deposit-with-swap) +- Page-load background priming (`primeSlotHintsFor(..., { background: true })`) on `pages/lend/[vault]/index.vue` and `pages/earn/[vault]/index.vue` β€” warms the form ref + batch registry without gating submit - Heavy single-shot `runSimulation` / `runPreparedSimulation` call-sites pass `buildStateOverrideOptions({ noBalanceOverride })` directly: - `composables/repay/useWalletSwapRepay.ts` - `composables/repay/useCollateralSwapRepay.ts` @@ -124,10 +125,43 @@ Wired into: `noBalanceOverride: true` is only safe when the operation either doesn't consume wallet ERC20 (collateral-swap repay, debt swap) or the form already gates submit on wallet balance (multiply, borrow, lend deposit, wallet-swap repay's EXACT_IN). Withdraw mode on `useCollateralForm` keeps the override but skips the balance branch by binding `noBalanceOverride` to `mode === 'supply'`. -`primeSlotHintsFor` is owner-/spender-agnostic; the SDK caches results in a module-scope `slotHintsCache` keyed on chain id + token, so a successful probe in one page warms the cache for every other page in the session. +`primeSlotHintsFor` is owner-/spender-agnostic. The SDK also memoises results in a module-scope `slotHintsCache` keyed on chain id + token. Lite additionally mirrors resolved hints into a chain-scoped registry (`composables/batchPrefetchState.ts`) so form pages and the batch cart share probes even when they do not share one SDK module cache across separately bundled call paths. + +Pass `background: true` for speculative page-load priming (lend/earn vault forms). Background primes still warm the local ref + registry, but they do **not** increment `useStateOverrideResolution().isResolvingStateOverrideHints`, so submit / add-to-batch stay usable while a cold probe runs. A miss only means the simulator falls back to `eth_createAccessList` discovery. + +Chain switches clear the local `slotHints` ref synchronously. Late probes for an old chain must not restore into the new chain’s local ref (they may still update that chain’s registry bucket). Concurrent primes re-merge after each await so an in-flight probe cannot clobber hints that landed meanwhile. See the SDK side: `packages/euler-v2-sdk/docs/simulations-and-state-overrides.md` (performance tuning section) and `packages/euler-v2-sdk/docs/execution-service.md` (prefetching plugin data). +## Batch cart prefetch + +The multi-tx batch cart (`composables/useTxBatch.ts`) reuses form-load accounts and slot hints so the first `addEntry` does not pay a full account refetch + access-list discovery tax. + +### Shared registry + +`composables/batchPrefetchState.ts` is a composable-free module registry (avoids import cycles between account / wallet / batch overlays): + +| Entry | Writer | Consumer | +| ----- | ------ | -------- | +| Planning account | `useFreshAccount` | First batch add that needs a planning account | +| Base / portfolio account | `useEulerAccount` | Layer-0 snapshot seed when the cart is empty | +| Slot hints by chain | `primeSlotHintsFor` / `primeBatchSlotHintsFor` | Every `addEntry` + `resimulate` | + +Both accounts are stored **pre-overlay**. Never read them back from layer-aware `usePlanAccount` / portfolio computeds β€” those return the active batch layer’s simulated account once a layer is active, which must never become the cart’s own layer 0. `useTxBatch` still validates chain + owner via `isAccountForContext` before reuse, because a wallet or chain switch can land before the matching loader replaces the registry. + +### Form β†’ batch slot-hint handoff + +1. On form load, lend/earn pages call `primeSlotHintsFor(tokens, { background: true })` for the vault asset (and lend pay-with assets). That writes the form’s local ref **and** `mergeBatchPrefetchedSlotHints(chainId, …)`. +2. Pages do **not** pass per-entry hint props into `addBatchEntry`. The registry is the handoff. +3. Each `addEntry` merges `getBatchPrefetchedSlotHints(cid)` into module-scope `batchSlotHints` (existing cart hints win on key conflict). +4. Only plan `requiredApproval` tokens still missing a hint are probed via `primeBatchSlotHintsFor`, which writes back into `batchSlotHints` and the registry. +5. `resimulate` always sends `stateOverrideOptions: { slotHints: batchSlotHints }`. +6. `batchSlotHints` lives for the cart lifetime and clears when the batch empties. + +### Simulation context freshness + +`resimulate` calls `simulateTransactionPlan(cid, ownerAddr, merged, …)` with the **current owner Address**, not a pinned `Account` object, so plugins resolve against the live owner. Layer 0 still comes from the pinned `baseAccountSnapshot` after the SDK stitch β€” entry plans are immutable add-time payloads, and later real-state drift must not rebuild the whole cart around a different base. + ## Swap Quotes `useSwapApi()` fetches swap quotes and normalizes the backend token shape into the SDK `SwapQuote` shape at the API boundary. Downstream planners pass `SwapApiQuote` directly into SDK planner methods. @@ -151,6 +185,8 @@ Lite still uses `utils/pyth.ts` for read-path lens simulations and visible vault | `composables/useEulerTx.ts` | Page-facing SDK planning, simulation preparation, and execution wrapper | | `composables/useTransactionPlanSimulation.ts` | Simulation state and error formatting for forms | | `composables/useStateOverrideOptions.ts` | `SimulationStateOverrideOptions` builder + per-token slot-hint priming | +| `composables/batchPrefetchState.ts` | Form β†’ batch handoff for pre-overlay accounts and chain-scoped slot hints | +| `composables/useTxBatch.ts` | Multi-tx cart: plan merge, resimulate, slot-hint reuse, execution | | `components/entities/operation/OperationReviewModal.vue` | Prepared-plan review, calldata copy, and Tenderly simulation | | `utils/stepDecoding.ts` | SDK plan item decoding for review display | | `utils/operationGuardRegistry.ts` | Guard transformer and blocker registry | From c7d1ab56e74936819aeb583ddeaa4aaf077865bf Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:50:46 +0100 Subject: [PATCH 11/52] fix: display oracle router activity events --- tests/utils/activity-display.test.ts | 40 ++++++++++++++++++++++++++++ utils/activity-display.ts | 9 +++++++ 2 files changed, 49 insertions(+) diff --git a/tests/utils/activity-display.test.ts b/tests/utils/activity-display.test.ts index c9f873740..8c088e603 100644 --- a/tests/utils/activity-display.test.ts +++ b/tests/utils/activity-display.test.ts @@ -73,6 +73,10 @@ describe('activity display helpers', () => { 'repay', 'set_caps', 'set_ltv', + 'set_oracle_config', + 'set_fallback_oracle', + 'set_resolved_vault', + 'set_oracle_governor', 'liquidation', ])) @@ -195,6 +199,8 @@ describe('activity display helpers', () => { expect(formatActivityEventLabel({ type: 'set_supply_cap' })).toBe('Set supply cap') expect(formatActivityEventLabel({ type: 'set_ltv' })).toBe('Set LTV') expect(formatActivityEventLabel({ type: 'set_interest_rate_model' })).toBe('Set interest rate model') + expect(formatActivityEventLabel({ type: 'set_oracle_config' })).toBe('Set oracle config') + expect(formatActivityEventLabel({ type: 'set_oracle_governor' })).toBe('Set oracle governor') }) it('labels and styles vault share transfers relative to the event position', () => { @@ -703,6 +709,40 @@ describe('activity display helpers', () => { }, getVaultMetadata)).toEqual([ { field: 'new_supply_cap', label: 'New supply cap', value: '155M USDC' }, ]) + + expect(getActivityChangeEntries({ + type: 'set_resolved_vault', + vault: VAULT, + vaultType: 'evk', + change: { + fields: { + router: ASSET, + resolved_vault: OTHER_VAULT, + asset: SHARES, + }, + }, + }, getVaultMetadata)).toEqual([ + { + field: 'asset', + label: 'Asset', + addresses: [{ address: SHARES, linkKind: 'explorer' }], + }, + { + field: 'resolved_vault', + label: 'Resolved vault', + addresses: [{ + address: OTHER_VAULT, + label: 'Collateral vault', + linkKind: 'vault', + vaultType: 'evk', + }], + }, + { + field: 'router', + label: 'Router', + addresses: [{ address: ASSET, linkKind: 'explorer' }], + }, + ]) }) it('orders LTV change fields and trims ramp fields on immediate changes', () => { diff --git a/utils/activity-display.ts b/utils/activity-display.ts index dcfc9eb87..2bb5754a8 100644 --- a/utils/activity-display.ts +++ b/utils/activity-display.ts @@ -110,6 +110,10 @@ const VAULT_ACTIVITY_EVENT_TYPES = { 'set_interest_rate_model', 'set_liquidation_cool_off_time', 'set_max_liquidation_discount', + 'set_oracle_config', + 'set_fallback_oracle', + 'set_resolved_vault', + 'set_oracle_governor', ], earn: [ 'deposit', @@ -829,6 +833,7 @@ const VAULT_ADDRESS_FIELDS_BY_EVENT: Partial { /** Display order for change fields whose upstream order is unhelpful. */ const CHANGE_FIELD_PRIORITY: Partial> = { + set_fallback_oracle: ['fallback_oracle', 'router'], set_ltv: [ 'collateral', 'borrow_ltv', @@ -959,6 +965,9 @@ const CHANGE_FIELD_PRIORITY: Partial Date: Fri, 31 Jul 2026 10:05:35 +0100 Subject: [PATCH 12/52] fix: clarify oracle route activity Resolve oracle route assets into a compact pair summary while preserving linked addresses in expanded details. Use semantic labels for oracle router governance events. --- .../entities/activity/ActivityEventRow.vue | 25 ++- tests/utils/activity-display.test.ts | 44 +++- utils/activity-display.ts | 190 +++++++++++------- 3 files changed, 179 insertions(+), 80 deletions(-) diff --git a/components/entities/activity/ActivityEventRow.vue b/components/entities/activity/ActivityEventRow.vue index adb8d3eab..fdcbb1dd4 100644 --- a/components/entities/activity/ActivityEventRow.vue +++ b/components/entities/activity/ActivityEventRow.vue @@ -56,6 +56,7 @@ const { registryVersion, } = useVaultRegistry() const { getTokenByAddress } = useTokenList() +const { buildKnownSymbols, resolveSymbol: resolveTokenSymbol } = useTokenSymbolResolver() const vaultAddress = computed(() => event.vault ?? '') const vaultProduct = useEulerProductOfVault(vaultAddress) const collateralVaultAddress = computed(() => @@ -93,6 +94,15 @@ const activityVaultMetadata = (address: `0x${string}`) => { } } +const knownTokenSymbols = computed(() => { + void registryVersion.value + return buildKnownSymbols() +}) + +const activityTokenSymbol = (address: `0x${string}`) => + tokenMetadata(address)?.symbol + ?? resolveTokenSymbol(address, knownTokenSymbols.value) + const resolveAvatarAsset = (asset: ActivityEvent['assets'][number]) => { const representsVaultShares = asset.kind === 'shares' || (event.category === 'liquidations' && ['collateral', 'yield'].includes(asset.kind)) @@ -179,7 +189,7 @@ const assets = computed(() => { const changes = computed(() => { // Re-resolve human-readable vault names when registry metadata arrives. void registryVersion.value - return getActivityChangeEntries(event, activityVaultMetadata) + return getActivityChangeEntries(event, activityVaultMetadata, activityTokenSymbol) .filter(entry => event.category !== 'liquidations' || entry.field !== 'collateral') .map(entry => ({ kind: 'change' as const, @@ -187,6 +197,7 @@ const changes = computed(() => { label: entry.label, value: entry.value, valueTitle: entry.value, + summary: entry.summary, addresses: entry.addresses, })) }) @@ -237,15 +248,17 @@ const portfolioPosition = computed(() => showVault const COLLAPSED_ENTRY_COUNT = 1 const hiddenEntryCount = computed(() => Math.max(0, details.value.length - COLLAPSED_ENTRY_COUNT)) -const addressCollectionSummary = (count: number) => - getActivityAddressCollectionSummary(event.type, count) +const addressCollectionSummary = (detail: (typeof details.value)[number]) => + 'summary' in detail && detail.summary + ? detail.summary + : getActivityAddressCollectionSummary(event.type, detailAddressCount(detail)) const detailAddressCount = (detail: (typeof details.value)[number]) => 'addresses' in detail && Array.isArray(detail.addresses) ? detail.addresses.length : 0 const hasExpandableDetails = computed(() => hiddenEntryCount.value > 0 || details.value .slice(0, COLLAPSED_ENTRY_COUNT) - .some(detail => addressCollectionSummary(detailAddressCount(detail)) !== null)) + .some(detail => addressCollectionSummary(detail) !== null)) const eventIcon = computed(() => getActivityEventIcon(event)) const eventLabel = computed(() => portfolioPosition.value ? `${portfolioPosition.value.label} liquidated` @@ -415,10 +428,10 @@ const vaultDisplay = computed(() => {