Feat/ projected APY estimates in form summaries#372
Conversation
- batch vault projected-rate reads for borrow and multiply summaries - weight position collateral APYs across all enabled collaterals - route repay and swap ROE through the shared rewards-aware helper
|
🚅 Deployed to the euler-lite-pr-372 environment in euler-lite
|
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository: euler-xyz/coderabbit/.coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR adds batched vault rate projections, a collateral-weighted APY snapshot hook, nullable-safe getRoe/getNetAPYFromWeightedSupplySnapshot helpers, and updates composables, pages, and tests to use batch projections and snapshot-driven APY/ROE flows. ChangesRate Projection and APY Calculation Refactor
Sequence DiagramsequenceDiagram
actor Comp as Composable/Page
participant Vault as Vault Module
participant Snapshot as usePositionCollateralApy
participant RPC as RPC/Lens
rect rgba(100, 150, 200, 0.5)
note over Comp,RPC: Old Flow (getNetAPY)
Comp->>Comp: Fetch collateralUsd, borrowUsd
Comp->>Vault: getNetAPY(collateralUsd, borrowUsd, ...)
Vault-->>Comp: netAPY
end
rect rgba(200, 150, 100, 0.5)
note over Comp,RPC: New Flow (getNetAPYFromWeightedSupplySnapshot)
Comp->>Snapshot: getCollateralApySnapshot(position, vault)
Snapshot->>RPC: Fetch collateral assets & rates
Snapshot->>Vault: getProjectedRatesBatch(requests)
Vault->>RPC: Multicall projections
RPC-->>Vault: projected rates array
Vault-->>Snapshot: ProjectedRates[]
Snapshot->>Snapshot: Aggregate weightedSupplyApy & supplyUsd
Snapshot-->>Comp: CollateralApySnapshot { supplyUsd, weightedSupplyApy }
Comp->>Vault: getNetAPYFromWeightedSupplySnapshot(snapshot, ...)
Vault-->>Comp: netAPY
end
sequenceDiagram
actor Page as Page Component
participant Vault as Vault Module
participant Comp as Composables
rect rgba(150, 200, 100, 0.5)
note over Page,Comp: Old ROE Calculation (calculateRoe)
Comp->>Comp: calculateRoe(supplyUsd, borrowUsd, supplyApy, borrowApy)
Comp-->>Page: roe
end
rect rgba(200, 100, 150, 0.5)
note over Page,Comp: New ROE Calculation (getRoe + null-safety)
Page->>Vault: getRoe(supplyUsd, supplyApy, borrowUsd, borrowApy)
Vault->>Vault: Handle null inputs, validate finite values
Vault-->>Page: roe | null
Page->>Page: Render with null coalescing (roe ?? 0)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
…apy-simulation-utilization-aware-rates
- Avoid double-counting supply rewards in weighted collateral Net APY - Weight collateral swap ROE across remaining and target collateral - Weight debt swap ROE across remaining and target liabilities
|
@CodeRabbit review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
pages/position/[number]/index.vue (1)
394-408:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winDon't coerce an unavailable ROE into a positive
0.When
getRoe()is unavailable, the card now renders-, but these paths still treat it as0: the text color stays in the non-negative state and the info modal receives0. That makes missing data look valid once the user interacts with it. Keep the null state through styling/modal props, or disable the ROE info action until the value is finite.Also applies to: 772-782
🤖 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/position/`[number]/index.vue around lines 394 - 408, The onRoeInfoClick handler (and the analogous handler around lines 772-782) is coercing an unavailable roe into 0; instead, preserve the absent/null state and avoid treating it as non-negative: stop using "roe.value ?? 0" and pass the raw roe value (or null) into modal.open so the modal receives a null/undefined when getRoe() is unavailable, and update any UI color/enable logic to check Number.isFinite(roe) (or Number.isFinite(roe.value)) before rendering positive styling or enabling the info action—if roe is not finite, disable the ROE info action (or short-circuit opening the modal) and keep the display as "-" so missing data isn’t shown as valid.composables/repay/useWalletRepay.ts (1)
274-299:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon’t keep rendering the previous net APY after a snapshot/projection failure.
This new
Promise.all(...)adds another fallible request, but the failure path still leaves_estimateNetAPYuntouched whilehasEstimatestaystrue. If any await rejects, the “after” APY for the current repay amount can silently reuse the previous input’s value. Reset the async APY state on failure so the UI falls back to a safe value instead of stale data.🤖 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/repay/useWalletRepay.ts` around lines 274 - 299, The Promise.all call combining getProjectedRates, getCollateralApySnapshot and getAssetUsdValueOrZero can reject and currently leaves _estimateNetAPY and hasEstimate stale; wrap that await in a try/catch around the block that computes projectedBorrowApy and _estimateNetAPY (references: getProjectedRates, getCollateralApySnapshot, getAssetUsdValueOrZero, asyncEstimatesGuard.isStale(gen), _estimateNetAPY, hasEstimate) and on any exception set/clear the async APY state (e.g. reset _estimateNetAPY to a safe default/null and set hasEstimate to false) before returning so the UI won’t display previous stale APY.composables/repay/useWalletSwapRepay.ts (1)
461-485:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReset the async APY fallback when the new snapshot request fails.
updateSyncEstimates()has already sethasEstimatetotrue, so ifgetCollateralApySnapshot(...)or one of the other awaited calls rejects here,_estimateNetAPYkeeps the previous successful value and the summary shows the wrong “after” APY for the current quote. Clear the async APY state in the failure path before dropping the loading flag.🤖 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/repay/useWalletSwapRepay.ts` around lines 461 - 485, The awaited Promise.all call that computes projected, collateralSnapshot and borrowUsd can throw and leave the previous _estimateNetAPY.value in place; wrap the Promise.all (and subsequent projectedBorrowApy/_estimateNetAPY calculation) in a try/catch, and in the catch reset the async APY fallback state (clear _estimateNetAPY.value to null or an explicit “no async estimate” value set by updateSyncEstimates()) before returning/propagating so the UI doesn't show the stale “after” APY; reference getProjectedRates, getCollateralApySnapshot, getAssetUsdValueOrZero, estimatesGuard.isStale(gen), and _estimateNetAPY in your change.composables/position/useCollateralForm.ts (1)
193-212:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winMissing race guard in async
watchEffectfornetAPY.The
watchEffectat line 193 performs async operations but lacks a race guard, unlikeupdateAsyncEstimateswhich usesasyncEstimatesGuard. Ifposition,borrowVault, orcollateralVaultchanges rapidly, stale promises could overwritenetAPY.valuewith outdated results.Consider adding a race guard similar to the pattern used in
updateAsyncEstimates:🛡️ Suggested fix
+const netAPYGuard = createRaceGuard() + watchEffect(async () => { if (!position.value || !borrowVault.value || !collateralVault.value) { netAPY.value = 0 return } + const gen = netAPYGuard.next() const [collateralSnapshot, borrowedUsd] = await Promise.all([ getCollateralApySnapshot(position.value, borrowVault.value), getAssetUsdValueOrZero(position.value.borrowed ?? 0n, borrowVault.value, 'off-chain'), ]) + if (netAPYGuard.isStale(gen)) return + netAPY.value = getNetAPYFromWeightedSupplySnapshot( collateralSnapshot, collateralSupplyApy.value, borrowedUsd, borrowApy.value, collateralSupplyRewardApy.value || null, borrowRewardApy.value || null, ) })🤖 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/position/useCollateralForm.ts` around lines 193 - 212, The async watchEffect that computes netAPY (watchEffect, netAPY.value) can suffer from races and stale overwrites; wrap the async work with the same race-guard pattern used in updateAsyncEstimates (the asyncEstimatesGuard token/counter) so only the latest invocation writes netAPY.value: capture a local guard token before awaiting getCollateralApySnapshot and getAssetUsdValueOrZero, then after the awaits verify the token is still current via asyncEstimatesGuard (or increment/compare the guard) and only then call getNetAPYFromWeightedSupplySnapshot and assign netAPY.value; ensure you reference the existing asyncEstimatesGuard mechanism rather than introducing a new ad-hoc flag.
🤖 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 `@composables/repay/useCollateralSwapRepay.ts`:
- Around line 190-205: The code only preserves currentSnapshot.weightedSupplyApy
in weightedCollateralSupplyApy so both before/after ROE calculations reuse the
old APY; capture and store nextSnapshot.weightedSupplyApy too and make
useRepayHealthMetrics (roeAfter) use the post-repay value. Concretely: keep both
current and next APY values (e.g., set weightedCollateralSupplyApyBefore =
currentSnapshot.weightedSupplyApy and weightedCollateralSupplyApyAfter =
nextSnapshot.weightedSupplyApy or store nextSnapshot into a dedicated ref), then
ensure effectiveCollateralSupplyApy/roeAfter resolution uses the “after” ref for
the post-repay calculation (also apply same change for the 208–218 block where
snapshots are computed).
In `@composables/repay/useRepayHealthMetrics.ts`:
- Around line 73-101: The async watchEffect updating projectedBorrowApy can be
overwritten by stale async completions; add a generation/race guard and failure
fallback: inside the watchEffect (the block using watchEffect, borrowVault,
position, debtRepaid, getProjectedRates and updating projectedBorrowApy) create
a local incremental token/generation (or use an AbortController-like guard used
elsewhere in the PR), capture the current token before awaiting
getProjectedRates, and after the await verify the token still matches before
assigning projectedBorrowApy; also wrap the getProjectedRates call in try/catch
and set projectedBorrowApy.value = null on any failure or if the token mismatch
indicates a stale response.
In `@composables/usePositionCollateralApy.ts`:
- Around line 84-169: getCollateralApySnapshot currently lets any async
rejection (config load, getOrFetch, getProjectedRatesBatch, price reads, etc.)
bubble up to callers; wrap the entire body of getCollateralApySnapshot in a
try/catch that catches all errors and returns the safe fallback { supplyUsd: 0,
weightedSupplyApy: null } on any failure, optionally logging the error; keep
existing early-return behavior when position/liabilityVault are missing and
ensure awaits like loadEulerConfig(), until(isVaultsReady).toBe(true),
getOrFetch(), getProjectedRatesBatch(), and getCollateralUsdValueOrZero() remain
inside the try so their failures are caught.
In `@pages/position/`[number]/borrow/swap.vue:
- Around line 281-340: The early return when quote.value is missing skips the
same-asset refinance path; change the condition to allow processing when
isSameAsset is true. Specifically, replace the "if (!quote.value ||
!toVault.value || !fromVault.value) { ... return }" guard to only return when
neither quote.value nor isSameAsset is present (and still require
toVault/fromVault). When isSameAsset is true derive newBorrowAmount from the
moved debt (use valueToNano(fromAmount.value, fromVault.value.decimals) or the
appropriate moved-amount variable) and then run the same Promise.all logic using
fromVault/toVault, getAssetUsdValue, and getProjectedRates so nextBorrowValueUsd
and nextBorrowApy are computed for same-asset refinances; keep the stale-gen
check (nextBorrowGuard.isStale(gen)) and all subsequent calculations unchanged.
In `@pages/position/`[number]/collateral/swap.vue:
- Around line 364-399: The current watchEffect uses roeSnapshotGuard.next() and
awaits getCollateralApySnapshot(...) but if that Promise rejects it leaves
supplyValueUsd, weightedSupplyApy, nextSupplyValueUsd and nextWeightedSupplyApy
with stale values; wrap the Promise.all/await in a try/catch (around the call
that assigns currentSnapshot/nextSnapshot) and in the catch check
roeSnapshotGuard.isStale(gen) before returning, otherwise set
supplyValueUsd.value = null, weightedSupplyApy.value = null,
nextSupplyValueUsd.value = null and nextWeightedSupplyApy.value = null and
return so failed snapshot requests clear the refs; keep using the existing
symbols getCollateralApySnapshot, roeSnapshotGuard.next()/isStale, and the four
ref names to locate changes.
In `@pages/position/`[number]/multiply.vue:
- Around line 295-310: The async watchEffect that calls getCollateralApySnapshot
can write stale results when inputs change rapidly; add a race guard using
onInvalidate (or a local cancellation token) inside the watchEffect: create a
local token (or boolean cancelled) at the start, register onInvalidate(() =>
mark token cancelled), capture the snapshot into a local variable, then before
assigning snapshot.supplyUsd to nextSupplyValueUsd.value and
snapshot.weightedSupplyApy to multiplyWeightedSupplyApy.value, check the token
and skip the assignment if invalidated; reference the existing watchEffect,
getCollateralApySnapshot, and the target variables
nextSupplyValueUsd/multiplyWeightedSupplyApy and inputs position,
multiplyShortVault, multiplyLongVault, multiplySwapAmountOut when implementing
the guard.
- Around line 275-284: The async watchEffect using getCollateralApySnapshot can
have race conditions where slower snapshot promises overwrite newer values;
modify the watchEffect to use Vue's onInvalidate (or a local cancel token)
inside the async callback (e.g., let cancelled = false; onInvalidate(()=>
cancelled = true)) and after awaiting getCollateralApySnapshot check the cancel
flag (or compare a local requestId) before assigning currentSupplyValueUsd.value
and currentWeightedSupplyApy.value, and ensure the early-return branch also sets
the cancel token so stale async results never mutate the refs (references:
watchEffect, getCollateralApySnapshot, currentSupplyValueUsd,
currentWeightedSupplyApy, position, multiplyShortVault).
---
Outside diff comments:
In `@composables/position/useCollateralForm.ts`:
- Around line 193-212: The async watchEffect that computes netAPY (watchEffect,
netAPY.value) can suffer from races and stale overwrites; wrap the async work
with the same race-guard pattern used in updateAsyncEstimates (the
asyncEstimatesGuard token/counter) so only the latest invocation writes
netAPY.value: capture a local guard token before awaiting
getCollateralApySnapshot and getAssetUsdValueOrZero, then after the awaits
verify the token is still current via asyncEstimatesGuard (or increment/compare
the guard) and only then call getNetAPYFromWeightedSupplySnapshot and assign
netAPY.value; ensure you reference the existing asyncEstimatesGuard mechanism
rather than introducing a new ad-hoc flag.
In `@composables/repay/useWalletRepay.ts`:
- Around line 274-299: The Promise.all call combining getProjectedRates,
getCollateralApySnapshot and getAssetUsdValueOrZero can reject and currently
leaves _estimateNetAPY and hasEstimate stale; wrap that await in a try/catch
around the block that computes projectedBorrowApy and _estimateNetAPY
(references: getProjectedRates, getCollateralApySnapshot,
getAssetUsdValueOrZero, asyncEstimatesGuard.isStale(gen), _estimateNetAPY,
hasEstimate) and on any exception set/clear the async APY state (e.g. reset
_estimateNetAPY to a safe default/null and set hasEstimate to false) before
returning so the UI won’t display previous stale APY.
In `@composables/repay/useWalletSwapRepay.ts`:
- Around line 461-485: The awaited Promise.all call that computes projected,
collateralSnapshot and borrowUsd can throw and leave the previous
_estimateNetAPY.value in place; wrap the Promise.all (and subsequent
projectedBorrowApy/_estimateNetAPY calculation) in a try/catch, and in the catch
reset the async APY fallback state (clear _estimateNetAPY.value to null or an
explicit “no async estimate” value set by updateSyncEstimates()) before
returning/propagating so the UI doesn't show the stale “after” APY; reference
getProjectedRates, getCollateralApySnapshot, getAssetUsdValueOrZero,
estimatesGuard.isStale(gen), and _estimateNetAPY in your change.
In `@pages/position/`[number]/index.vue:
- Around line 394-408: The onRoeInfoClick handler (and the analogous handler
around lines 772-782) is coercing an unavailable roe into 0; instead, preserve
the absent/null state and avoid treating it as non-negative: stop using
"roe.value ?? 0" and pass the raw roe value (or null) into modal.open so the
modal receives a null/undefined when getRoe() is unavailable, and update any UI
color/enable logic to check Number.isFinite(roe) (or Number.isFinite(roe.value))
before rendering positive styling or enabling the info action—if roe is not
finite, disable the ROE info action (or short-circuit opening the modal) and
keep the display as "-" so missing data isn’t shown as valid.
🪄 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: 9129e768-b439-4d87-86fd-faa5056fba3e
📒 Files selected for processing (21)
composables/borrow/useBorrowForm.tscomposables/borrow/useMultiplyForm.tscomposables/position/useCollateralForm.tscomposables/repay/useCollateralSwapRepay.tscomposables/repay/useRepayHealthMetrics.tscomposables/repay/useSavingsRepay.tscomposables/repay/useWalletRepay.tscomposables/repay/useWalletSwapRepay.tscomposables/usePositionCollateralApy.tsentities/vault/apy.tsentities/vault/index.tspages/position/[number]/borrow/index.vuepages/position/[number]/borrow/swap.vuepages/position/[number]/collateral/swap.vuepages/position/[number]/index.vuepages/position/[number]/multiply.vuepages/position/[number]/repay.vuetests/composables/useSavingsRepay.test.tstests/entities/vault/apy.test.tstests/utils/repay-utils.test.tsutils/repayUtils.ts
💤 Files with no reviewable changes (1)
- utils/repayUtils.ts
LeonardEulerXYZ
left a comment
There was a problem hiding this comment.
Deep agent-assisted re-review
Reviewed current head 504c9e7 with four independent passes: APY/ROE math, async/reactive behavior, transaction/same-asset trust boundaries, and tests/coverage. I then re-anchored the material findings against the current code before posting.
Validation run from /home/leonard/euler-lite-worktrees/pr-372:
npm run typecheck -- --noEmit✅npm run test:run -- tests/entities/vault/apy.test.ts tests/utils/repay-utils.test.ts tests/composables/useSavingsRepay.test.ts✅ (60tests)npm run test:run✅ (623passed,1skipped)npm run build✅
CodeRabbit critique status
I re-checked the 7 CodeRabbit critiques on the current head:
useCollateralSwapRepaypost-repay weighted collateral APY forroeAfter— resolved; current and next weighted APYs are now separated.useRepayHealthMetricsstale projected borrow APY updates — resolved; the projected borrow watcher uses a race guard and active-failure clearing.usePositionCollateralApysafe fallback — resolved;getCollateralApySnapshot()now catches and returns{ supplyUsd: 0, weightedSupplyApy: null }.- borrow refinance same-asset projected path — same-asset path itself is resolved; the watcher no longer requires
quote.valuefor same-asset and usescurrentDebt.valuethere. - collateral swap ROE snapshot failure clearing — no longer actionable as originally stated because the shared snapshot helper now catches and returns the safe snapshot.
- multiply current snapshot race guard — resolved.
- multiply next snapshot race guard — resolved.
Blocking finding
One blocker remains from the deeper transaction-path pass:
- Cross-asset debt refinance still derives the quote amount and projected old-debt reduction from the truncated display string
fromAmount, while the actual transaction plan/verifier is full-refinance (targetDebt: 0n,currentDebt: currentDebt.value). The same-asset path was fixed to use exactcurrentDebt.value, but the cross-asset path still has a quote/summary-vs-plan mismatch. For example,1234567890123456789at 18 decimals is displayed as1.23456, which parses back to1234560000000000000, short by7890123456789wei. Dust-sized debts can be much worse proportionally.
Expected invariant: for a “refinance full current debt” flow, the quote request and projected debt summary should use the same exact debt amount as the transaction/verifier path, not the shortened UI display string.
Non-blocking hardening
- The new collateral portfolio watchers in
useSavingsRepay/useCollateralSwapRepayclear refs on invalid dependencies before advancing their race guard, so an older in-flight snapshot can theoretically repopulate stale derived ROE/APY values after invalidation. This is display/derived-metric stale-state hardening, not a direct transaction construction issue, so I’m not treating it as the blocker here.
Test coverage note
The green suite is useful compatibility evidence, but there is still no focused regression coverage for the cross-asset debt-refinance exact-amount invariant. A good test would mount/harness pages/position/[number]/borrow/swap.vue with a high-precision currentDebt, assert the displayed max may be shortened, but verify the quote request/projected summary/full-refinance plan all use exact currentDebt.value for the debt being refinanced.
Summary
Changes
getNetAPYFromWeightedSupplySnapshotso reward-inclusive weighted collateral APYs cannot double-count supply rewards when passed into Net APY calculations.Test plan
npm run typechecknpm run lintnpm run test:run -- tests/entities/vault/apy.test.ts tests/utils/repay-utils.test.tsnpm run test:run -- tests/entities/vault/apy.test.ts tests/utils/repay-utils.test.ts tests/composables/useSavingsRepay.test.tsnpm run test:run -- tests/entities/vault/apy.test.ts tests/utils/repay-utils.test.ts tests/composables/useSavingsRepay.test.ts tests/composables/repay-review-amount.test.tsnpm run build/,/borrow?network=1,/position/0/repay?network=1,/position/0/multiply?network=1,/position/0/supply?network=1,/position/0/withdraw?network=1,/position/0/borrow?network=1, and/position/0/collateral/swap?network=1.0x68e7E72938db36a5CBbCa7b52c71DBBaaDfB8264, position1: overview, borrow, multiply, repay, borrow swap, and collateral swap routes loaded without PR-specific crashes.Routed via, projected ROE/health/LTV/liquidation fields updated, and the previous TDZ crash did not recur.External Review Notes
a968fd6fpassed typecheck, lint, targeted Vitest, build, Railway preview deploy, and 13-route local/preview/comparator matrix.getNetAPYwith a separate supply reward APY;e092c5dbwas reviewed and verified:getRoe/getNetAPYargument order looked correct.2b6bc3bfverified the stale async guards:73b4a6aeadds focused APY tests covering reward-inclusive weighted snapshots and fallback reward behavior.Smoke Fixture
0x68e7E72938db36a5CBbCa7b52c71DBBaaDfB826411,2https://production-master-branch-euler-lite-pr-372.up.railway.apphttps://euler-development-production.up.railway.appSmoke Route Matrix
Routes checked across local built PR server, PR preview, and development comparator:
Result:
Summary by CodeRabbit
New Features
Improvements
Tests